feat: 优化排课管理
This commit is contained in:
@@ -75,12 +75,6 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "pages/admin/week-template",
|
|
||||||
"style": {
|
|
||||||
"navigationStyle": "custom"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/admin/slot-adjust",
|
"path": "pages/admin/slot-adjust",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -63,21 +63,6 @@
|
|||||||
<text class="arrow-text">›</text>
|
<text class="arrow-text">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="list-item" @tap="navigate('/pages/admin/week-template')">
|
|
||||||
<view class="item-left">
|
|
||||||
<view class="item-icon-wrap icon--template">
|
|
||||||
<text class="item-icon-text">◈</text>
|
|
||||||
</view>
|
|
||||||
<view class="item-text-group">
|
|
||||||
<text class="item-title">排课模板</text>
|
|
||||||
<text class="item-desc">设置每周课程模板</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="item-arrow">
|
|
||||||
<text class="arrow-text">›</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Section header: 会员与订单 -->
|
<!-- Section header: 会员与订单 -->
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<view v-else-if="editableSlots.length === 0" class="empty-state">
|
<view v-else-if="editableSlots.length === 0" class="empty-state">
|
||||||
<text class="empty-icon">📭</text>
|
<text class="empty-icon">📭</text>
|
||||||
<text class="empty-text">当日暂无排课</text>
|
<text class="empty-text">当日暂无排课</text>
|
||||||
<text class="empty-sub">无模板匹配,请手动添加时段或先配置排课模板</text>
|
<text class="empty-sub">当日暂无默认时段,请点击下方按钮手动添加</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Slot list -->
|
<!-- Slot list -->
|
||||||
@@ -404,7 +404,7 @@ function slotBadgeClass(slot: EditableSlot): string {
|
|||||||
function slotBadgeText(slot: EditableSlot): string {
|
function slotBadgeText(slot: EditableSlot): string {
|
||||||
if (slot.isNew) return '新增'
|
if (slot.isNew) return '新增'
|
||||||
if (slot.isPublished) return '已发布'
|
if (slot.isPublished) return '已发布'
|
||||||
return '来自模板'
|
return '默认时段'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lifecycle ─────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="gen-hint">将根据排课模板,自动生成所选日期范围内的时段</text>
|
<text class="gen-hint">将按默认时间表(每天 8:00-22:00,每小时一节)自动生成所选日期范围内的时段</text>
|
||||||
<view class="action-wrap">
|
<view class="action-wrap">
|
||||||
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
|
<view class="action-btn" :class="{ 'action-btn--loading': submitting }" @tap="submitGenerate">
|
||||||
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
|
<text class="action-btn-text">{{ submitting ? '生成中...' : '批量生成' }}</text>
|
||||||
|
|||||||
@@ -1,528 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
|
||||||
<CustomNavBar title="排课模板" show-back />
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<view class="toolbar">
|
|
||||||
<text class="toolbar-hint">共 {{ templates.length }} 条模板</text>
|
|
||||||
<view class="add-btn" @tap="openAdd">
|
|
||||||
<text class="add-btn-text">+ 新增时段</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Loading skeleton -->
|
|
||||||
<view v-if="loading" class="skeleton-list">
|
|
||||||
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Empty -->
|
|
||||||
<view v-else-if="!templates.length" class="empty-state">
|
|
||||||
<text class="empty-icon">📅</text>
|
|
||||||
<text class="empty-text">暂无模板,点击右上角新增</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Template list grouped by weekday -->
|
|
||||||
<view v-else>
|
|
||||||
<view v-for="(group, day) in grouped" :key="day" class="day-group">
|
|
||||||
<view class="day-header">
|
|
||||||
<text class="day-label">{{ WEEKDAY_LABELS[Number(day)] }}</text>
|
|
||||||
<text class="day-count">{{ group.length }} 个时段</text>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
v-for="tpl in group"
|
|
||||||
:key="tpl.id ?? tpl._key"
|
|
||||||
class="tpl-row"
|
|
||||||
:class="{ 'tpl-row--inactive': !tpl.isActive }"
|
|
||||||
>
|
|
||||||
<view class="tpl-time">
|
|
||||||
<text class="tpl-time-text">{{ tpl.startTime }} – {{ tpl.endTime }}</text>
|
|
||||||
<text class="tpl-capacity">{{ tpl.capacity }} 人</text>
|
|
||||||
</view>
|
|
||||||
<view class="tpl-actions">
|
|
||||||
<view
|
|
||||||
class="tpl-toggle"
|
|
||||||
:class="tpl.isActive ? 'toggle--on' : 'toggle--off'"
|
|
||||||
@tap="toggleTemplate(tpl)"
|
|
||||||
>
|
|
||||||
<text class="tpl-toggle-text">{{ tpl.isActive ? '启用' : '停用' }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="tpl-edit" @tap="openEdit(tpl)">
|
|
||||||
<text class="tpl-edit-text">编辑</text>
|
|
||||||
</view>
|
|
||||||
<view class="tpl-delete" @tap="deleteTemplate(tpl)">
|
|
||||||
<text class="tpl-delete-text">删除</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Save bar -->
|
|
||||||
<view v-if="isDirty" class="save-bar">
|
|
||||||
<view class="save-btn" :class="{ 'save-btn--loading': saving }" @tap="handleSave">
|
|
||||||
<text class="save-btn-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- Add / Edit modal -->
|
|
||||||
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
|
|
||||||
<view class="modal">
|
|
||||||
<text class="modal-title">{{ editTarget ? '编辑时段' : '新增时段' }}</text>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">星期</text>
|
|
||||||
<picker
|
|
||||||
mode="selector"
|
|
||||||
:range="dayOptions"
|
|
||||||
range-key="label"
|
|
||||||
:value="form.dayIdx"
|
|
||||||
@change="(e: any) => form.dayIdx = Number(e.detail.value)"
|
|
||||||
>
|
|
||||||
<view class="picker-display">
|
|
||||||
<text class="picker-text">{{ dayOptions[form.dayIdx].label }}</text>
|
|
||||||
<text class="picker-arrow">›</text>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">开始时间</text>
|
|
||||||
<picker
|
|
||||||
mode="time"
|
|
||||||
:value="form.startTime"
|
|
||||||
@change="(e: any) => form.startTime = e.detail.value"
|
|
||||||
>
|
|
||||||
<view class="picker-display">
|
|
||||||
<text class="picker-text">{{ form.startTime || '请选择' }}</text>
|
|
||||||
<text class="picker-arrow">›</text>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field">
|
|
||||||
<text class="modal-label">结束时间</text>
|
|
||||||
<picker
|
|
||||||
mode="time"
|
|
||||||
:value="form.endTime"
|
|
||||||
@change="(e: any) => form.endTime = e.detail.value"
|
|
||||||
>
|
|
||||||
<view class="picker-display">
|
|
||||||
<text class="picker-text">{{ form.endTime || '请选择' }}</text>
|
|
||||||
<text class="picker-arrow">›</text>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-field modal-field--last">
|
|
||||||
<text class="modal-label">容量</text>
|
|
||||||
<input
|
|
||||||
class="modal-input"
|
|
||||||
type="number"
|
|
||||||
v-model="form.capacityStr"
|
|
||||||
placeholder="如:10"
|
|
||||||
placeholder-style="color:#bbb"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="modal-actions">
|
|
||||||
<view class="modal-cancel" @tap="closeModal">
|
|
||||||
<text class="modal-cancel-text">取消</text>
|
|
||||||
</view>
|
|
||||||
<view class="modal-confirm" @tap="submitForm">
|
|
||||||
<text class="modal-confirm-text">确认</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
|
||||||
import { getSystemLayout } from '../../utils/system'
|
|
||||||
import { useAdminStore } from '../../stores/admin'
|
|
||||||
import { WEEKDAY_LABELS } from '@mp-pilates/shared'
|
|
||||||
import type { WeekTemplate } from '@mp-pilates/shared'
|
|
||||||
|
|
||||||
type LocalTemplate = Partial<WeekTemplate> & {
|
|
||||||
_key?: string
|
|
||||||
dayOfWeek: number
|
|
||||||
startTime: string
|
|
||||||
endTime: string
|
|
||||||
capacity: number
|
|
||||||
isActive: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminStore = useAdminStore()
|
|
||||||
const navBarHeight = ref('64px')
|
|
||||||
const loading = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const isDirty = ref(false)
|
|
||||||
const showModal = ref(false)
|
|
||||||
const editTarget = ref<LocalTemplate | null>(null)
|
|
||||||
|
|
||||||
const templates = ref<LocalTemplate[]>([])
|
|
||||||
|
|
||||||
const dayOptions = [1, 2, 3, 4, 5, 6, 7].map((d) => ({ label: WEEKDAY_LABELS[d], value: d }))
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
dayIdx: 0,
|
|
||||||
startTime: '08:00',
|
|
||||||
endTime: '09:00',
|
|
||||||
capacityStr: '1',
|
|
||||||
})
|
|
||||||
|
|
||||||
const grouped = computed(() => {
|
|
||||||
const map: Record<number, LocalTemplate[]> = {}
|
|
||||||
for (const tpl of templates.value) {
|
|
||||||
if (!map[tpl.dayOfWeek]) map[tpl.dayOfWeek] = []
|
|
||||||
map[tpl.dayOfWeek].push(tpl)
|
|
||||||
}
|
|
||||||
// Sort by day
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(map).sort(([a], [b]) => Number(a) - Number(b)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 生成默认模板:周一到周日,8:00-22:00 每小时一个时段 */
|
|
||||||
function generateDefaultTemplates(): LocalTemplate[] {
|
|
||||||
const defaults: LocalTemplate[] = []
|
|
||||||
for (let day = 1; day <= 7; day++) {
|
|
||||||
for (let hour = 8; hour < 22; hour++) {
|
|
||||||
const start = String(hour).padStart(2, '0') + ':00'
|
|
||||||
const end = String(hour + 1).padStart(2, '0') + ':00'
|
|
||||||
defaults.push({
|
|
||||||
_key: `default-${day}-${start}`,
|
|
||||||
dayOfWeek: day,
|
|
||||||
startTime: start,
|
|
||||||
endTime: end,
|
|
||||||
capacity: 1,
|
|
||||||
isActive: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaults
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTemplates() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const data = await adminStore.fetchWeekTemplates()
|
|
||||||
if (data.length === 0) {
|
|
||||||
// No templates yet — pre-fill with defaults
|
|
||||||
templates.value = generateDefaultTemplates()
|
|
||||||
isDirty.value = true
|
|
||||||
} else {
|
|
||||||
templates.value = data
|
|
||||||
isDirty.value = false
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAdd() {
|
|
||||||
editTarget.value = null
|
|
||||||
form.value = { dayIdx: 0, startTime: '08:00', endTime: '09:00', capacityStr: '1' }
|
|
||||||
showModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(tpl: LocalTemplate) {
|
|
||||||
editTarget.value = tpl
|
|
||||||
const dayIdx = dayOptions.findIndex((d) => d.value === tpl.dayOfWeek)
|
|
||||||
form.value = {
|
|
||||||
dayIdx: dayIdx >= 0 ? dayIdx : 0,
|
|
||||||
startTime: tpl.startTime,
|
|
||||||
endTime: tpl.endTime,
|
|
||||||
capacityStr: String(tpl.capacity),
|
|
||||||
}
|
|
||||||
showModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
showModal.value = false
|
|
||||||
editTarget.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitForm() {
|
|
||||||
const capacity = parseInt(form.value.capacityStr, 10)
|
|
||||||
if (!form.value.startTime || !form.value.endTime) {
|
|
||||||
uni.showToast({ title: '请填写时间', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isNaN(capacity) || capacity < 1) {
|
|
||||||
uni.showToast({ title: '请填写有效容量', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const day = dayOptions[form.value.dayIdx].value
|
|
||||||
|
|
||||||
if (editTarget.value) {
|
|
||||||
const tpl = editTarget.value
|
|
||||||
tpl.dayOfWeek = day
|
|
||||||
tpl.startTime = form.value.startTime
|
|
||||||
tpl.endTime = form.value.endTime
|
|
||||||
tpl.capacity = capacity
|
|
||||||
} else {
|
|
||||||
templates.value.push({
|
|
||||||
_key: String(Date.now()),
|
|
||||||
dayOfWeek: day,
|
|
||||||
startTime: form.value.startTime,
|
|
||||||
endTime: form.value.endTime,
|
|
||||||
capacity,
|
|
||||||
isActive: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
isDirty.value = true
|
|
||||||
closeModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTemplate(tpl: LocalTemplate) {
|
|
||||||
tpl.isActive = !tpl.isActive
|
|
||||||
isDirty.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteTemplate(tpl: LocalTemplate) {
|
|
||||||
uni.showModal({
|
|
||||||
title: '确认删除',
|
|
||||||
content: '删除该时段模板?',
|
|
||||||
success: (res) => {
|
|
||||||
if (res.confirm) {
|
|
||||||
const idx = templates.value.indexOf(tpl)
|
|
||||||
if (idx >= 0) templates.value.splice(idx, 1)
|
|
||||||
isDirty.value = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (saving.value) return
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const payload = templates.value.map((t) => ({
|
|
||||||
id: t.id,
|
|
||||||
dayOfWeek: t.dayOfWeek,
|
|
||||||
startTime: t.startTime,
|
|
||||||
endTime: t.endTime,
|
|
||||||
capacity: t.capacity,
|
|
||||||
isActive: t.isActive,
|
|
||||||
}))
|
|
||||||
await adminStore.saveWeekTemplates(payload as any)
|
|
||||||
isDirty.value = false
|
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
|
||||||
await fetchTemplates()
|
|
||||||
} catch (e: any) {
|
|
||||||
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
|
||||||
fetchTemplates()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f5f3f0;
|
|
||||||
padding-bottom: 120rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Toolbar ─────────────────────────────── */
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 24rpx 24rpx 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-hint { font-size: 24rpx; color: #999; }
|
|
||||||
|
|
||||||
.add-btn {
|
|
||||||
background: #1a1a2e;
|
|
||||||
border-radius: 32rpx;
|
|
||||||
padding: 12rpx 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-btn-text { font-size: 26rpx; font-weight: 600; color: $primary-dark; }
|
|
||||||
|
|
||||||
/* ── Skeleton ────────────────────────────── */
|
|
||||||
.skeleton-list { padding: 0 24rpx; }
|
|
||||||
|
|
||||||
.skeleton-item {
|
|
||||||
height: 80rpx;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
||||||
background-size: 400% 100%;
|
|
||||||
animation: shimmer 1.4s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty ───────────────────────────────── */
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 100rpx 0;
|
|
||||||
gap: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon { font-size: 80rpx; }
|
|
||||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
|
||||||
|
|
||||||
/* ── Day group ───────────────────────────── */
|
|
||||||
.day-group { margin: 0 24rpx 24rpx; }
|
|
||||||
|
|
||||||
.day-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16rpx 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-label { font-size: 28rpx; font-weight: 700; color: #1a1a2e; }
|
|
||||||
.day-count { font-size: 22rpx; color: #999; }
|
|
||||||
|
|
||||||
/* ── Template row ────────────────────────── */
|
|
||||||
.tpl-row {
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
padding: 20rpx 24rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 12rpx;
|
|
||||||
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
|
|
||||||
|
|
||||||
&--inactive { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.tpl-time { display: flex; flex-direction: column; gap: 6rpx; }
|
|
||||||
.tpl-time-text { font-size: 28rpx; font-weight: 600; color: #1a1a2e; }
|
|
||||||
.tpl-capacity { font-size: 22rpx; color: #888; }
|
|
||||||
|
|
||||||
.tpl-actions { display: flex; gap: 12rpx; }
|
|
||||||
|
|
||||||
.tpl-toggle,
|
|
||||||
.tpl-edit,
|
|
||||||
.tpl-delete {
|
|
||||||
border-radius: 20rpx;
|
|
||||||
padding: 8rpx 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle--on { background: rgba(39,174,96,0.12); }
|
|
||||||
.toggle--on .tpl-toggle-text { font-size: 24rpx; color: #27ae60; }
|
|
||||||
.toggle--off { background: rgba(230,126,34,0.12); }
|
|
||||||
.toggle--off .tpl-toggle-text { font-size: 24rpx; color: #e67e22; }
|
|
||||||
|
|
||||||
.tpl-edit { background: rgba(26,26,46,0.08); }
|
|
||||||
.tpl-edit-text { font-size: 24rpx; color: #1a1a2e; }
|
|
||||||
|
|
||||||
.tpl-delete { background: rgba(192,57,43,0.08); }
|
|
||||||
.tpl-delete-text { font-size: 24rpx; color: #c0392b; }
|
|
||||||
|
|
||||||
/* ── Save bar ────────────────────────────── */
|
|
||||||
.save-bar {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 20rpx 24rpx 48rpx;
|
|
||||||
background: #ffffff;
|
|
||||||
box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 96rpx;
|
|
||||||
border-radius: 48rpx;
|
|
||||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&--loading { opacity: 0.6; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn-text { font-size: 30rpx; font-weight: 700; color: $primary-dark; }
|
|
||||||
|
|
||||||
/* ── Modal ───────────────────────────────── */
|
|
||||||
.modal-mask {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
width: 100%;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 24rpx 24rpx 0 0;
|
|
||||||
padding: 40rpx 32rpx 60rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a2e;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-field {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 24rpx 0;
|
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
|
||||||
|
|
||||||
&--last { border-bottom: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-label { font-size: 26rpx; color: #555; width: 140rpx; flex-shrink: 0; }
|
|
||||||
|
|
||||||
.modal-input { flex: 1; text-align: right; font-size: 26rpx; color: #222; }
|
|
||||||
|
|
||||||
.picker-display { display: flex; align-items: center; gap: 8rpx; }
|
|
||||||
.picker-text { font-size: 26rpx; color: #222; }
|
|
||||||
.picker-arrow { font-size: 26rpx; color: #bbb; }
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 16rpx;
|
|
||||||
margin-top: 32rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-cancel {
|
|
||||||
flex: 1;
|
|
||||||
height: 88rpx;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 44rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-cancel-text { font-size: 28rpx; color: #555; }
|
|
||||||
|
|
||||||
.modal-confirm {
|
|
||||||
flex: 2;
|
|
||||||
height: 88rpx;
|
|
||||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
|
||||||
border-radius: 44rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: $primary-dark; }
|
|
||||||
</style>
|
|
||||||
@@ -2,8 +2,6 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { get, post, put, del } from '../utils/request'
|
import { get, post, put, del } from '../utils/request'
|
||||||
import type {
|
import type {
|
||||||
WeekTemplate,
|
|
||||||
WeekTemplateInput,
|
|
||||||
CardType,
|
CardType,
|
||||||
CreateCardTypeDto,
|
CreateCardTypeDto,
|
||||||
UpdateCardTypeDto,
|
UpdateCardTypeDto,
|
||||||
@@ -86,21 +84,6 @@ export interface UserMembership {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useAdminStore = defineStore('admin', () => {
|
export const useAdminStore = defineStore('admin', () => {
|
||||||
// ── Week templates ───────────────────────────────────────────────
|
|
||||||
const weekTemplates = ref<WeekTemplate[]>([])
|
|
||||||
|
|
||||||
async function fetchWeekTemplates(): Promise<WeekTemplate[]> {
|
|
||||||
const data = await get<WeekTemplate[]>('/admin/week-template')
|
|
||||||
weekTemplates.value = data
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveWeekTemplates(templates: WeekTemplateInput[]): Promise<WeekTemplate[]> {
|
|
||||||
const data = await put<WeekTemplate[]>('/admin/week-template', { templates })
|
|
||||||
weekTemplates.value = data
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Card types ───────────────────────────────────────────────────
|
// ── Card types ───────────────────────────────────────────────────
|
||||||
const cardTypes = ref<CardType[]>([])
|
const cardTypes = ref<CardType[]>([])
|
||||||
|
|
||||||
@@ -273,14 +256,10 @@ export const useAdminStore = defineStore('admin', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
weekTemplates,
|
|
||||||
cardTypes,
|
cardTypes,
|
||||||
studioConfig,
|
studioConfig,
|
||||||
schedulePreview,
|
schedulePreview,
|
||||||
scheduleLoading,
|
scheduleLoading,
|
||||||
// Week templates
|
|
||||||
fetchWeekTemplates,
|
|
||||||
saveWeekTemplates,
|
|
||||||
// Card types
|
// Card types
|
||||||
fetchCardTypes,
|
fetchCardTypes,
|
||||||
createCardType,
|
createCardType,
|
||||||
|
|||||||
@@ -6,40 +6,14 @@ import {
|
|||||||
TimeSlotSource,
|
TimeSlotSource,
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
BookingStatus,
|
BookingStatus,
|
||||||
|
getDefaultTimeSlots,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Return a Date whose JS getDay() maps to the given ISO weekday (1=Mon…7=Sun) */
|
|
||||||
function dateForIsoWeekday(isoWeekday: number): Date {
|
|
||||||
const base = new Date('2026-04-06T00:00:00Z') // Monday
|
|
||||||
const d = new Date(base)
|
|
||||||
d.setDate(base.getDate() + (isoWeekday - 1))
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeTemplate = (overrides: Record<string, unknown> = {}) => ({
|
|
||||||
id: 'tpl-1',
|
|
||||||
dayOfWeek: 1,
|
|
||||||
startTime: '09:00',
|
|
||||||
endTime: '10:00',
|
|
||||||
capacity: 1,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
...overrides,
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mock PrismaService
|
// Mock PrismaService
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const mockPrisma = {
|
const mockPrisma = {
|
||||||
weekTemplate: {
|
|
||||||
findMany: jest.fn(),
|
|
||||||
},
|
|
||||||
timeSlot: {
|
timeSlot: {
|
||||||
createMany: jest.fn(),
|
createMany: jest.fn(),
|
||||||
updateMany: jest.fn(),
|
updateMany: jest.fn(),
|
||||||
@@ -77,26 +51,12 @@ describe('SlotGeneratorService', () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('generateSlots', () => {
|
describe('generateSlots', () => {
|
||||||
it('returns 0 when there are no active templates', async () => {
|
it('creates slots for every day using the default schedule (14 slots per day)', async () => {
|
||||||
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([])
|
const defaultSlots = getDefaultTimeSlots()
|
||||||
|
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: defaultSlots.length * 7 })
|
||||||
|
|
||||||
const count = await service.generateSlots(7)
|
const count = await service.generateSlots(7)
|
||||||
|
|
||||||
expect(count).toBe(0)
|
|
||||||
expect(mockPrisma.timeSlot.createMany).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates correct number of slots from templates', async () => {
|
|
||||||
// 2 templates, both for Monday (ISO 1)
|
|
||||||
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
|
|
||||||
makeTemplate({ id: 'tpl-1', dayOfWeek: 1, startTime: '09:00', endTime: '10:00' }),
|
|
||||||
makeTemplate({ id: 'tpl-2', dayOfWeek: 1, startTime: '10:00', endTime: '11:00' }),
|
|
||||||
])
|
|
||||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 2 })
|
|
||||||
|
|
||||||
// Use 7 days — will hit exactly one Monday
|
|
||||||
const count = await service.generateSlots(7)
|
|
||||||
|
|
||||||
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
|
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
|
||||||
const { data, skipDuplicates } =
|
const { data, skipDuplicates } =
|
||||||
mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||||
@@ -104,23 +64,30 @@ describe('SlotGeneratorService', () => {
|
|||||||
skipDuplicates: boolean
|
skipDuplicates: boolean
|
||||||
}
|
}
|
||||||
expect(skipDuplicates).toBe(true)
|
expect(skipDuplicates).toBe(true)
|
||||||
// Both templates should appear in the batch (may include more days)
|
// 7 days × 14 slots per day = 98
|
||||||
const mondaySlots = (
|
expect(data).toHaveLength(defaultSlots.length * 7)
|
||||||
data as Array<{ startTime: string; source: TimeSlotSource }>
|
expect(count).toBe(defaultSlots.length * 7)
|
||||||
).filter(
|
})
|
||||||
(s) => s.startTime === '09:00' || s.startTime === '10:00',
|
|
||||||
)
|
it('creates 14 slots per day (08:00-22:00 hourly)', async () => {
|
||||||
expect(mondaySlots.length).toBeGreaterThanOrEqual(2)
|
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 })
|
||||||
expect(count).toBe(2)
|
|
||||||
|
await service.generateSlots(1)
|
||||||
|
|
||||||
|
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||||
|
data: Array<{ startTime: string; endTime: string }>
|
||||||
|
}
|
||||||
|
expect(data).toHaveLength(14)
|
||||||
|
expect(data[0].startTime).toBe('08:00')
|
||||||
|
expect(data[0].endTime).toBe('09:00')
|
||||||
|
expect(data[13].startTime).toBe('21:00')
|
||||||
|
expect(data[13].endTime).toBe('22:00')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('passes skipDuplicates: true to handle existing date+time combinations', async () => {
|
it('passes skipDuplicates: true to handle existing date+time combinations', async () => {
|
||||||
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
|
|
||||||
makeTemplate({ dayOfWeek: 1 }),
|
|
||||||
])
|
|
||||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
|
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 0 })
|
||||||
|
|
||||||
await service.generateSlots(7)
|
await service.generateSlots(1)
|
||||||
|
|
||||||
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
const call = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||||
skipDuplicates: boolean
|
skipDuplicates: boolean
|
||||||
@@ -128,54 +95,17 @@ describe('SlotGeneratorService', () => {
|
|||||||
expect(call.skipDuplicates).toBe(true)
|
expect(call.skipDuplicates).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps Sunday (JS getDay()=0) to ISO weekday 7', async () => {
|
it('sets source to TEMPLATE for all generated slots', async () => {
|
||||||
// Template for Sunday (ISO 7)
|
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 14 })
|
||||||
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
|
|
||||||
makeTemplate({ id: 'tpl-sun', dayOfWeek: 7, startTime: '08:00', endTime: '09:00' }),
|
|
||||||
])
|
|
||||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
|
|
||||||
|
|
||||||
// 7 days will cover exactly one Sunday
|
await service.generateSlots(1)
|
||||||
const count = await service.generateSlots(7)
|
|
||||||
|
|
||||||
expect(mockPrisma.timeSlot.createMany).toHaveBeenCalledTimes(1)
|
|
||||||
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
|
||||||
data: Array<{ startTime: string; source: TimeSlotSource }>
|
|
||||||
}
|
|
||||||
const sundaySlots = data.filter((s) => s.startTime === '08:00')
|
|
||||||
expect(sundaySlots.length).toBeGreaterThanOrEqual(1)
|
|
||||||
expect(sundaySlots[0].source).toBe(TimeSlotSource.TEMPLATE)
|
|
||||||
expect(count).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps Monday (JS getDay()=1) to ISO weekday 1', async () => {
|
|
||||||
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
|
|
||||||
makeTemplate({ id: 'tpl-mon', dayOfWeek: 1, startTime: '07:00', endTime: '08:00' }),
|
|
||||||
])
|
|
||||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
|
|
||||||
|
|
||||||
await service.generateSlots(7)
|
|
||||||
|
|
||||||
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
||||||
data: Array<{ startTime: string }>
|
data: Array<{ source: TimeSlotSource }>
|
||||||
}
|
}
|
||||||
const mondaySlots = data.filter((s) => s.startTime === '07:00')
|
for (const slot of data) {
|
||||||
expect(mondaySlots.length).toBeGreaterThanOrEqual(1)
|
expect(slot.source).toBe(TimeSlotSource.TEMPLATE)
|
||||||
})
|
|
||||||
|
|
||||||
it('attaches templateId from the matching template', async () => {
|
|
||||||
mockPrisma.weekTemplate.findMany.mockResolvedValueOnce([
|
|
||||||
makeTemplate({ id: 'tpl-xyz', dayOfWeek: 2 }),
|
|
||||||
])
|
|
||||||
mockPrisma.timeSlot.createMany.mockResolvedValueOnce({ count: 1 })
|
|
||||||
|
|
||||||
await service.generateSlots(7)
|
|
||||||
|
|
||||||
const { data } = mockPrisma.timeSlot.createMany.mock.calls[0][0] as {
|
|
||||||
data: Array<{ templateId: string }>
|
|
||||||
}
|
}
|
||||||
const tuesdaySlots = data.filter((s) => s.templateId === 'tpl-xyz')
|
|
||||||
expect(tuesdaySlots.length).toBeGreaterThanOrEqual(1)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,10 @@ import {
|
|||||||
BookingStatus,
|
BookingStatus,
|
||||||
SLOT_GENERATION_DAYS,
|
SLOT_GENERATION_DAYS,
|
||||||
DEFAULT_SLOT_CAPACITY,
|
DEFAULT_SLOT_CAPACITY,
|
||||||
|
getDefaultTimeSlots,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
|
||||||
/** Convert JS getDay() (0=Sun … 6=Sat) to ISO weekday (1=Mon … 7=Sun) */
|
|
||||||
function toIsoWeekday(jsDay: number): number {
|
|
||||||
return jsDay === 0 ? 7 : jsDay
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a UTC Date for midnight of a local calendar date */
|
/** Build a UTC Date for midnight of a local calendar date */
|
||||||
function toUtcMidnight(date: Date): Date {
|
function toUtcMidnight(date: Date): Date {
|
||||||
const d = new Date(date)
|
const d = new Date(date)
|
||||||
@@ -28,20 +24,14 @@ export class SlotGeneratorService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate time slots for the next `daysAhead` days based on active
|
* Generate time slots for the next `daysAhead` days based on the fixed
|
||||||
* WeekTemplates. Uses `createMany` with `skipDuplicates` so re-runs are safe.
|
* default schedule (Mon-Sun, 08:00-22:00 hourly).
|
||||||
|
* Uses `createMany` with `skipDuplicates` so re-runs are safe.
|
||||||
*
|
*
|
||||||
* @returns Number of newly created slots
|
* @returns Number of newly created slots
|
||||||
*/
|
*/
|
||||||
async generateSlots(daysAhead: number = SLOT_GENERATION_DAYS): Promise<number> {
|
async generateSlots(daysAhead: number = SLOT_GENERATION_DAYS): Promise<number> {
|
||||||
const templates = await this.prisma.weekTemplate.findMany({
|
const defaultSlots = getDefaultTimeSlots()
|
||||||
where: { isActive: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (templates.length === 0) {
|
|
||||||
this.logger.log('No active week templates found – skipping slot generation')
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const tomorrow = new Date()
|
const tomorrow = new Date()
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
@@ -53,27 +43,19 @@ export class SlotGeneratorService {
|
|||||||
endTime: string
|
endTime: string
|
||||||
capacity: number
|
capacity: number
|
||||||
source: TimeSlotSource
|
source: TimeSlotSource
|
||||||
templateId: string
|
|
||||||
}> = []
|
}> = []
|
||||||
|
|
||||||
for (let offset = 0; offset < daysAhead; offset++) {
|
for (let offset = 0; offset < daysAhead; offset++) {
|
||||||
const target = new Date(tomorrow)
|
const target = new Date(tomorrow)
|
||||||
target.setDate(target.getDate() + offset)
|
target.setDate(target.getDate() + offset)
|
||||||
|
|
||||||
const isoWeekday = toIsoWeekday(target.getDay())
|
for (const slot of defaultSlots) {
|
||||||
|
|
||||||
const matchingTemplates = templates.filter(
|
|
||||||
(t) => t.dayOfWeek === isoWeekday,
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const template of matchingTemplates) {
|
|
||||||
slotsToCreate.push({
|
slotsToCreate.push({
|
||||||
date: toUtcMidnight(target),
|
date: toUtcMidnight(target),
|
||||||
startTime: template.startTime,
|
startTime: slot.startTime,
|
||||||
endTime: template.endTime,
|
endTime: slot.endTime,
|
||||||
capacity: template.capacity ?? DEFAULT_SLOT_CAPACITY,
|
capacity: DEFAULT_SLOT_CAPACITY,
|
||||||
source: TimeSlotSource.TEMPLATE,
|
source: TimeSlotSource.TEMPLATE,
|
||||||
templateId: template.id,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { TimeSlotService } from './time-slot.service'
|
|||||||
import { SlotGeneratorService } from './slot-generator.service'
|
import { SlotGeneratorService } from './slot-generator.service'
|
||||||
import { QuerySlotsDto } from './dto/query-slots.dto'
|
import { QuerySlotsDto } from './dto/query-slots.dto'
|
||||||
import { CreateManualSlotDto } from './dto/create-manual-slot.dto'
|
import { CreateManualSlotDto } from './dto/create-manual-slot.dto'
|
||||||
import { UpdateWeekTemplateDto } from './dto/week-template.dto'
|
|
||||||
import { PublishDaySlotsDto } from './dto/publish-day-slots.dto'
|
import { PublishDaySlotsDto } from './dto/publish-day-slots.dto'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -61,18 +60,6 @@ export class AdminTimeSlotController {
|
|||||||
private readonly slotGeneratorService: SlotGeneratorService,
|
private readonly slotGeneratorService: SlotGeneratorService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Week template management
|
|
||||||
|
|
||||||
@Get('week-template')
|
|
||||||
getWeekTemplates() {
|
|
||||||
return this.timeSlotService.getWeekTemplates()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put('week-template')
|
|
||||||
replaceWeekTemplates(@Body() dto: UpdateWeekTemplateDto) {
|
|
||||||
return this.timeSlotService.replaceWeekTemplates(dto.templates)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manual slot management
|
// Manual slot management
|
||||||
|
|
||||||
@Post('time-slot/manual')
|
@Post('time-slot/manual')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'
|
||||||
import { TimeSlotStatus, BookingStatus, DEFAULT_SLOT_CAPACITY } from '@mp-pilates/shared'
|
import { TimeSlotStatus, BookingStatus, DEFAULT_SLOT_CAPACITY, getDefaultTimeSlots } from '@mp-pilates/shared'
|
||||||
import { TimeSlotSource } from '@mp-pilates/shared'
|
import { TimeSlotSource } from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import type { TimeSlotWithBookingStatus, ScheduleSlotPreview } from '@mp-pilates/shared'
|
import type { TimeSlotWithBookingStatus, ScheduleSlotPreview } from '@mp-pilates/shared'
|
||||||
@@ -144,51 +144,12 @@ export class TimeSlotService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWeekTemplates() {
|
|
||||||
return this.prisma.weekTemplate.findMany({
|
|
||||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async replaceWeekTemplates(
|
|
||||||
items: Array<{
|
|
||||||
dayOfWeek: number
|
|
||||||
startTime: string
|
|
||||||
endTime: string
|
|
||||||
capacity?: number
|
|
||||||
isActive?: boolean
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
|
||||||
await tx.weekTemplate.deleteMany()
|
|
||||||
|
|
||||||
await tx.weekTemplate.createMany({
|
|
||||||
data: items.map((item) => ({
|
|
||||||
dayOfWeek: item.dayOfWeek,
|
|
||||||
startTime: item.startTime,
|
|
||||||
endTime: item.endTime,
|
|
||||||
capacity: item.capacity ?? DEFAULT_SLOT_CAPACITY,
|
|
||||||
isActive: item.isActive ?? true,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
return tx.weekTemplate.findMany({
|
|
||||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Schedule preview & publish ──────────────────────────────
|
// ── Schedule preview & publish ──────────────────────────────
|
||||||
|
|
||||||
/** Convert JS getDay() (0=Sun … 6=Sat) to ISO weekday (1=Mon … 7=Sun) */
|
|
||||||
private toIsoWeekday(jsDay: number): number {
|
|
||||||
return jsDay === 0 ? 7 : jsDay
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a schedule preview for a given date.
|
* Return a schedule preview for a given date.
|
||||||
* If TimeSlot records already exist → return them (isPublished: true).
|
* If TimeSlot records already exist → return them (isPublished: true).
|
||||||
* Otherwise → derive from active WeekTemplates (isPublished: false).
|
* Otherwise → derive from the fixed default schedule (isPublished: false).
|
||||||
*/
|
*/
|
||||||
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
|
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
|
||||||
const parsedDate = new Date(date)
|
const parsedDate = new Date(date)
|
||||||
@@ -216,23 +177,19 @@ export class TimeSlotService {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. No existing slots — derive from WeekTemplate
|
// 2. No existing slots — use fixed default schedule
|
||||||
const isoWeekday = this.toIsoWeekday(parsedDate.getUTCDay())
|
const defaultSlots = getDefaultTimeSlots()
|
||||||
const templates = await this.prisma.weekTemplate.findMany({
|
|
||||||
where: { dayOfWeek: isoWeekday, isActive: true },
|
|
||||||
orderBy: { startTime: 'asc' },
|
|
||||||
})
|
|
||||||
|
|
||||||
return templates.map((tpl) => ({
|
return defaultSlots.map((slot) => ({
|
||||||
id: null,
|
id: null,
|
||||||
date: date,
|
date: date,
|
||||||
startTime: tpl.startTime,
|
startTime: slot.startTime,
|
||||||
endTime: tpl.endTime,
|
endTime: slot.endTime,
|
||||||
capacity: tpl.capacity,
|
capacity: DEFAULT_SLOT_CAPACITY,
|
||||||
bookedCount: 0,
|
bookedCount: 0,
|
||||||
status: TimeSlotStatus.OPEN,
|
status: TimeSlotStatus.OPEN,
|
||||||
source: TimeSlotSource.TEMPLATE,
|
source: TimeSlotSource.TEMPLATE,
|
||||||
templateId: tpl.id,
|
templateId: null,
|
||||||
isPublished: false,
|
isPublished: false,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
|
"rootDir": "../..",
|
||||||
"module": "CommonJS",
|
"module": "CommonJS",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
"ignoreDeprecations": "5.0",
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"target": "ES2021",
|
"target": "ES2021",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -15,6 +15,21 @@ export const DEFAULT_SLOT_CAPACITY = 1
|
|||||||
/** 自动生成时段的天数范围 */
|
/** 自动生成时段的天数范围 */
|
||||||
export const SLOT_GENERATION_DAYS = 14
|
export const SLOT_GENERATION_DAYS = 14
|
||||||
|
|
||||||
|
/** 默认排课时间表:每天 08:00-22:00,每小时一节课 */
|
||||||
|
export const DEFAULT_SCHEDULE_START_HOUR = 8
|
||||||
|
export const DEFAULT_SCHEDULE_END_HOUR = 22
|
||||||
|
|
||||||
|
/** 生成默认时段列表 (startTime, endTime) */
|
||||||
|
export function getDefaultTimeSlots(): ReadonlyArray<{ readonly startTime: string; readonly endTime: string }> {
|
||||||
|
const slots: Array<{ startTime: string; endTime: string }> = []
|
||||||
|
for (let h = DEFAULT_SCHEDULE_START_HOUR; h < DEFAULT_SCHEDULE_END_HOUR; h++) {
|
||||||
|
const startTime = String(h).padStart(2, '0') + ':00'
|
||||||
|
const endTime = String(h + 1).padStart(2, '0') + ':00'
|
||||||
|
slots.push({ startTime, endTime })
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
/** 时段筛选区间 */
|
/** 时段筛选区间 */
|
||||||
export const TIME_PERIODS = {
|
export const TIME_PERIODS = {
|
||||||
MORNING: { label: '上午', start: '06:00', end: '12:00' },
|
MORNING: { label: '上午', start: '06:00', end: '12:00' },
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export {
|
|||||||
DEFAULT_STUDIO_GALLERY_PHOTOS,
|
DEFAULT_STUDIO_GALLERY_PHOTOS,
|
||||||
DEFAULT_SLOT_CAPACITY,
|
DEFAULT_SLOT_CAPACITY,
|
||||||
SLOT_GENERATION_DAYS,
|
SLOT_GENERATION_DAYS,
|
||||||
|
DEFAULT_SCHEDULE_START_HOUR,
|
||||||
|
DEFAULT_SCHEDULE_END_HOUR,
|
||||||
|
getDefaultTimeSlots,
|
||||||
TIME_PERIODS,
|
TIME_PERIODS,
|
||||||
DATE_SELECTOR_DAYS,
|
DATE_SELECTOR_DAYS,
|
||||||
WEEKDAY_LABELS,
|
WEEKDAY_LABELS,
|
||||||
|
|||||||
Reference in New Issue
Block a user