feat:實作預約系統 — 時段管理、預約生命週期與前端整合

後端:
- 新增 course_schedules / bookings migration(含索引)
- BookingStatus / ScheduleStatus PHP BackedEnum
- CourseSchedule / Booking Model(七狀態機 VALID_TRANSITIONS)
- ScheduleController、ProviderBookingController、MemberBookingController
- 雙層名額驗證(API 層快速失敗 + DB lockForUpdate 防超賣)
- 24h 取消截止、pending 不佔位設計
- ExpirePendingBookings(每小時)/ CompleteFinishedBookings(每日)Scheduler
- Docker cron 配置、CACHE_STORE 改為 file 修正 502

前端:
- 課程詳情頁加入時段選擇與預約流程
- 我的預約頁(展開式卡片、狀態說明、連結課程詳情)
- Coach 時段管理(上午/下午時間選擇器、新課程引導)
- Coach 預約管理(依課程分組、待確認徽章)
- Navbar 新增「我的預約」與「時段/預約管理」入口
- 密碼格式提示與即時比對

OpenSpec:
- booking-system change 歸檔至 archive/2026-05-12-booking-system
- 新增 specs/course-scheduling 與 specs/booking-lifecycle 主規格

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 00:24:51 +08:00
parent 24145419b0
commit 88c0c09ccb
40 changed files with 2202 additions and 18 deletions
+166
View File
@@ -0,0 +1,166 @@
<script setup>
import { ref, onMounted } from 'vue'
import { getMyBookings, cancelBooking } from '../api/bookingApi'
const bookings = ref([])
const loading = ref(true)
const error = ref('')
const expanded = ref(new Set())
const STATUS_LABEL = {
pending: { text: '待教練確認', color: 'bg-yellow-100 text-yellow-700', hint: '等待教練確認中,確認後才完成預約' },
confirmed: { text: '預約成功', color: 'bg-green-100 text-green-700', hint: '教練已確認,請準時出席' },
completed: { text: '已完成', color: 'bg-gray-100 text-gray-600', hint: '' },
rejected: { text: '已拒絕', color: 'bg-red-100 text-red-600', hint: '教練無法接受此預約' },
expired: { text: '已過期', color: 'bg-gray-100 text-gray-400', hint: '超過 48 小時未獲確認,預約自動取消' },
member_cancelled: { text: '已取消', color: 'bg-gray-100 text-gray-500', hint: '' },
provider_cancelled: { text: '教練取消', color: 'bg-orange-100 text-orange-600', hint: '教練因故取消此預約' },
}
onMounted(async () => {
try {
const res = await getMyBookings()
bookings.value = res.data.data
} catch {
error.value = '無法載入預約記錄'
} finally {
loading.value = false
}
})
function toggle(id) {
if (expanded.value.has(id)) expanded.value.delete(id)
else expanded.value.add(id)
}
async function doCancel(booking) {
if (!confirm('確定要取消此預約?')) return
try {
await cancelBooking(booking.id)
booking.status = 'member_cancelled'
} catch (e) {
alert(e.response?.data?.message || '取消失敗')
}
}
function canCancel(status) {
return status === 'pending' || status === 'confirmed'
}
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}/${String(d.getMonth()+1).padStart(2,'0')}/${String(d.getDate()).padStart(2,'0')}`
}
</script>
<template>
<main class="max-w-3xl mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-gray-800 mb-6">我的預約</h1>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="error" class="text-center text-red-500 py-10">{{ error }}</div>
<div v-else-if="bookings.length === 0" class="text-center text-gray-400 py-20">
目前沒有預約記錄<RouterLink to="/courses" class="text-ocean-600 underline">瀏覽課程</RouterLink>
</div>
<div v-else class="space-y-3">
<div
v-for="b in bookings"
:key="b.id"
class="bg-white rounded-2xl shadow border border-gray-100 overflow-hidden"
>
<!-- 摘要列點擊展開 -->
<button
class="w-full text-left px-5 py-4 flex items-center justify-between gap-4 hover:bg-gray-50 transition"
@click="toggle(b.id)"
>
<div class="flex-1 min-w-0">
<p class="font-semibold text-gray-800 truncate">{{ b.offer_title }}</p>
<p class="text-sm text-gray-500 mt-0.5">
{{ b.scheduled_date }} {{ b.start_time }}
{{ b.participants }}
NT$ {{ b.total_price?.toLocaleString() }}
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_LABEL[b.status]?.color">
{{ STATUS_LABEL[b.status]?.text || b.status }}
</span>
<span class="text-gray-400 text-sm">{{ expanded.has(b.id) ? '▲' : '▼' }}</span>
</div>
</button>
<!-- 展開詳情 -->
<div v-if="expanded.has(b.id)" class="border-t border-gray-100 px-5 py-4 space-y-4 bg-gray-50">
<!-- 狀態說明 -->
<div v-if="STATUS_LABEL[b.status]?.hint"
class="flex items-center gap-2 text-sm rounded-lg px-3 py-2"
:class="b.status === 'pending' ? 'bg-yellow-50 text-yellow-700' : b.status === 'confirmed' ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'"
>
<span>{{ b.status === 'pending' ? '⏳' : b.status === 'confirmed' ? '✅' : '️' }}</span>
<span>{{ STATUS_LABEL[b.status].hint }}</span>
</div>
<!-- 課程與時段資訊 -->
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div>
<p class="text-gray-400 text-xs mb-0.5">課程名稱</p>
<p class="text-gray-700 font-medium">{{ b.offer_title }}</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">地點</p>
<p class="text-gray-700">{{ b.offer_location || '—' }}
<span v-if="b.offer_region" class="text-gray-400">{{ b.offer_region }}</span>
</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">上課日期</p>
<p class="text-gray-700">{{ b.scheduled_date }} {{ b.start_time }}</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">預約人數</p>
<p class="text-gray-700">{{ b.participants }} </p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">課程單價</p>
<p class="text-gray-700">NT$ {{ b.offer_price?.toLocaleString() }}</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-0.5">總金額</p>
<p class="text-gray-800 font-semibold">NT$ {{ b.total_price?.toLocaleString() }}</p>
</div>
<div v-if="b.notes" class="col-span-2">
<p class="text-gray-400 text-xs mb-0.5">備注</p>
<p class="text-gray-600">{{ b.notes }}</p>
</div>
<div class="col-span-2">
<p class="text-gray-400 text-xs mb-0.5">預約時間</p>
<p class="text-gray-500 text-xs">{{ b.created_at ? new Date(b.created_at).toLocaleString('zh-TW') : '—' }}</p>
</div>
</div>
<!-- 操作按鈕列 -->
<div class="flex items-center justify-between pt-1">
<RouterLink
v-if="b.offer_id"
:to="`/courses/${b.offer_id}`"
class="text-sm text-ocean-600 hover:text-ocean-800 hover:underline"
>
查看課程介紹
</RouterLink>
<span v-else></span>
<button
v-if="canCancel(b.status)"
@click="doCancel(b)"
class="text-sm text-red-500 hover:text-red-700 underline"
>
取消預約
</button>
</div>
</div>
</div>
</div>
</main>
</template>