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 ad2c05779d
commit 975b56ca54
40 changed files with 2202 additions and 18 deletions
+17
View File
@@ -0,0 +1,17 @@
import api from './axios'
export function getMyBookings() {
return api.get('/member/bookings')
}
export function getBooking(id) {
return api.get(`/member/bookings/${id}`)
}
export function createBooking(payload) {
return api.post('/member/bookings', payload)
}
export function cancelBooking(id) {
return api.delete(`/member/bookings/${id}`)
}
+17
View File
@@ -0,0 +1,17 @@
import coachApi from './coachAxios'
export function getProviderBookings() {
return coachApi.get('/provider/bookings')
}
export function confirmBooking(id) {
return coachApi.put(`/provider/bookings/${id}/confirm`)
}
export function rejectBooking(id) {
return coachApi.put(`/provider/bookings/${id}/reject`)
}
export function cancelBooking(id) {
return coachApi.put(`/provider/bookings/${id}/cancel`)
}
+17
View File
@@ -0,0 +1,17 @@
import coachApi from './coachAxios'
export function getSchedules() {
return coachApi.get('/provider/schedules')
}
export function createSchedule(payload) {
return coachApi.post('/provider/schedules', payload)
}
export function updateSchedule(id, payload) {
return coachApi.put(`/provider/schedules/${id}`, payload)
}
export function deleteSchedule(id) {
return coachApi.delete(`/provider/schedules/${id}`)
}
+10
View File
@@ -0,0 +1,10 @@
import axios from 'axios'
const publicApi = axios.create({
baseURL: import.meta.env.VITE_API_URL + '/api',
headers: { Accept: 'application/json' },
})
export function getSchedulesByOffer(offerId) {
return publicApi.get(`/diving-offers/${offerId}/schedules`)
}
+4 -2
View File
@@ -18,8 +18,10 @@ async function handleLogout() {
<RouterLink to="/coach/dashboard" class="text-lg font-bold tracking-wide hover:text-gray-300 transition">
🤿 Coach Portal
</RouterLink>
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</RouterLink>
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</RouterLink>
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
</div>
<div class="flex items-center gap-4 text-sm">
+1
View File
@@ -25,6 +25,7 @@ async function handleLogout() {
<span class="text-ocean-200 hidden sm:inline">
👤 {{ auth.user?.name }}
</span>
<RouterLink to="/my-bookings" class="hover:text-ocean-100 transition">我的預約</RouterLink>
<RouterLink to="/profile" class="hover:text-ocean-100 transition">個人資料</RouterLink>
<button
@click="handleLogout"
+4 -1
View File
@@ -11,7 +11,8 @@ const routes = [
{ path: '/login', component: () => import('../views/LoginView.vue') },
{ path: '/register', component: () => import('../views/RegisterView.vue') },
{ path: '/auth/callback', component: () => import('../views/AuthCallbackView.vue') },
{ path: '/profile', component: () => import('../views/ProfileView.vue'), meta: { requiresAuth: true } },
{ path: '/profile', component: () => import('../views/ProfileView.vue'), meta: { requiresAuth: true } },
{ path: '/my-bookings', component: () => import('../views/MyBookingsView.vue'), meta: { requiresAuth: true } },
// Coach (public)
{ path: '/coach/login', component: () => import('../views/coach/LoginView.vue') },
@@ -26,6 +27,8 @@ const routes = [
{ path: 'offers/new', component: () => import('../views/coach/OfferFormView.vue') },
{ path: 'offers/:id/edit', component: () => import('../views/coach/OfferFormView.vue') },
{ path: 'profile', component: () => import('../views/coach/ProfileView.vue') },
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
],
},
+87 -7
View File
@@ -2,24 +2,47 @@
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../api/axios'
import { getSchedulesByOffer } from '../api/scheduleApi'
import { createBooking } from '../api/bookingApi'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const offer = ref(null)
const loading = ref(true)
const notFound = ref(false)
const offer = ref(null)
const loading = ref(true)
const notFound = ref(false)
const schedules = ref([])
const selected = ref(null)
const participants = ref(1)
const booking = ref({ loading: false, success: false, error: '' })
onMounted(async () => {
try {
const res = await api.get(`/diving-offers/${route.params.id}`)
offer.value = res.data.data
const sRes = await getSchedulesByOffer(route.params.id)
schedules.value = sRes.data.data
} catch (e) {
notFound.value = true
} finally {
loading.value = false
}
})
async function submitBooking() {
if (!selected.value) return
booking.value = { loading: true, success: false, error: '' }
try {
await createBooking({ schedule_id: selected.value.id, participants: participants.value })
booking.value.success = true
} catch (e) {
booking.value.error = e.response?.data?.message || '預約失敗,請稍後再試'
} finally {
booking.value.loading = false
}
}
</script>
<template>
@@ -65,14 +88,71 @@ onMounted(async () => {
<p class="text-gray-700 leading-relaxed whitespace-pre-wrap">{{ offer.description || '暫無課程說明。' }}</p>
</div>
<div class="flex items-center justify-between bg-ocean-50 rounded-2xl p-6">
<div class="flex items-center justify-between bg-ocean-50 rounded-2xl p-6 mb-6">
<div>
<p class="text-sm text-gray-500">課程費用</p>
<p class="text-3xl font-bold text-ocean-800">NT$ {{ offer.price.toLocaleString() }}</p>
</div>
<button class="bg-ocean-700 hover:bg-ocean-600 text-white font-semibold px-8 py-3 rounded-full transition">
立即洽詢
</button>
</div>
<!-- 可用時段 -->
<div class="bg-white rounded-2xl shadow p-6 mb-6">
<div class="flex items-start gap-2 mb-4">
<h2 class="text-lg font-semibold text-gray-800">可預約時段</h2>
</div>
<div class="flex items-center gap-2 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4 text-sm text-amber-700">
<span></span>
<span>送出預約後需等待教練確認確認後才算預約成功</span>
</div>
<div v-if="schedules.length === 0" class="text-gray-400 text-sm">目前沒有開放時段</div>
<div v-else class="space-y-3">
<label
v-for="s in schedules"
:key="s.id"
class="flex items-center justify-between border rounded-xl px-4 py-3 cursor-pointer transition"
:class="selected?.id === s.id ? 'border-ocean-600 bg-ocean-50' : 'border-gray-200 hover:border-ocean-400'"
>
<div class="flex items-center gap-3">
<input type="radio" :value="s" v-model="selected" class="accent-ocean-600" />
<div>
<p class="font-medium text-gray-800">{{ s.scheduled_date }} {{ s.start_time }}</p>
<p class="text-sm text-gray-500">剩餘名額{{ s.remaining_spots }} </p>
</div>
</div>
<p class="text-ocean-700 font-semibold">NT$ {{ (offer.price * participants).toLocaleString() }}</p>
</label>
</div>
<!-- 人數選擇與預約按鈕 -->
<div v-if="selected" class="mt-5 border-t pt-4">
<div class="flex items-center gap-4 mb-4">
<label class="text-sm text-gray-600">預約人數</label>
<input
v-model.number="participants"
type="number"
min="1"
:max="selected.remaining_spots"
class="border rounded-lg px-3 py-1 w-20 text-center"
/>
</div>
<div v-if="booking.success" class="text-green-600 text-sm mb-3">✓ 預約已送出!請等待教練確認。前往 <RouterLink to="/my-bookings" class="underline">我的預約</RouterLink> 查看</div>
<div v-if="booking.error" class="text-red-500 text-sm mb-3">{{ booking.error }}</div>
<div v-if="!auth.isLoggedIn" class="text-sm text-gray-500">
請先 <RouterLink to="/login" class="text-ocean-600 underline">登入</RouterLink> 才能預約
</div>
<button
v-else
@click="submitBooking"
:disabled="booking.loading || booking.success"
class="w-full bg-ocean-700 hover:bg-ocean-600 disabled:opacity-50 text-white font-semibold py-3 rounded-full transition"
>
{{ booking.loading ? '送出中...' : booking.success ? '已送出預約' : '立即預約' }}
</button>
</div>
</div>
</template>
+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>
+3
View File
@@ -72,6 +72,7 @@ async function submit() {
minlength="8"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-ocean-400"
/>
<p class="text-xs text-gray-400 mt-1">至少 8 個字元</p>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">確認密碼</label>
@@ -80,7 +81,9 @@ async function submit() {
type="password"
required
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-ocean-400"
:class="confirm && confirm !== password ? 'border-red-400' : ''"
/>
<p v-if="confirm && confirm !== password" class="text-xs text-red-500 mt-1">密碼不一致</p>
</div>
<button
@@ -0,0 +1,129 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { getProviderBookings, confirmBooking, rejectBooking, cancelBooking } from '../../api/coachBookingApi'
const bookings = ref([])
const loading = ref(true)
const STATUS_LABEL = {
pending: { text: '待確認', color: 'bg-yellow-100 text-yellow-700' },
confirmed: { text: '已確認', color: 'bg-green-100 text-green-700' },
completed: { text: '已完成', color: 'bg-gray-100 text-gray-600' },
rejected: { text: '已拒絕', color: 'bg-red-100 text-red-600' },
expired: { text: '已過期', color: 'bg-gray-100 text-gray-400' },
member_cancelled: { text: '學員取消', color: 'bg-gray-100 text-gray-500' },
provider_cancelled: { text: '教練取消', color: 'bg-orange-100 text-orange-600' },
}
// 依課程名稱分組,同課程再依時段日期排序
const groupedByOffer = computed(() => {
const map = {}
for (const b of bookings.value) {
const key = b.offer_title || '未知課程'
if (!map[key]) map[key] = []
map[key].push(b)
}
// 每組內依日期排序
for (const key of Object.keys(map)) {
map[key].sort((a, b) => (a.scheduled_date + a.start_time).localeCompare(b.scheduled_date + b.start_time))
}
return map
})
const pendingCount = computed(() => bookings.value.filter(b => b.status === 'pending').length)
onMounted(fetchBookings)
async function fetchBookings() {
loading.value = true
try {
const res = await getProviderBookings()
bookings.value = res.data.data
} finally {
loading.value = false
}
}
async function doAction(booking, action) {
const labels = { confirm: '確認', reject: '拒絕', cancel: '取消' }
if (!confirm(`確定要${labels[action]}此預約?`)) return
try {
if (action === 'confirm') await confirmBooking(booking.id)
if (action === 'reject') await rejectBooking(booking.id)
if (action === 'cancel') await cancelBooking(booking.id)
await fetchBookings()
} catch (e) {
alert(e.response?.data?.message || '操作失敗')
}
}
</script>
<template>
<div class="p-6 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800">預約管理</h1>
<span v-if="pendingCount > 0"
class="bg-yellow-100 text-yellow-700 text-sm font-medium px-3 py-1 rounded-full">
{{ pendingCount }} 筆待確認
</span>
</div>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="bookings.length === 0" class="text-center text-gray-400 py-20">目前沒有任何預約</div>
<div v-else class="space-y-8">
<!-- 依課程分組 -->
<div v-for="(group, offerTitle) in groupedByOffer" :key="offerTitle">
<!-- 課程標題列 -->
<div class="flex items-center gap-3 mb-3">
<div class="h-px flex-1 bg-gray-200"></div>
<h2 class="text-sm font-semibold text-gray-500 whitespace-nowrap px-1">🤿 {{ offerTitle }}</h2>
<div class="h-px flex-1 bg-gray-200"></div>
</div>
<!-- 同課程的預約列表 -->
<div class="space-y-2">
<div
v-for="b in group"
:key="b.id"
class="bg-white rounded-xl border px-5 py-4 flex items-start justify-between flex-wrap gap-3"
:class="b.status === 'pending' ? 'border-yellow-200 shadow-sm' : 'border-gray-100'"
>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-700">
{{ b.scheduled_date }} {{ b.start_time }}
</p>
<p class="text-sm text-gray-500 mt-0.5">
{{ b.member_name }}
<span class="text-gray-400">{{ b.member_email }}</span>
{{ b.participants }} NT$ {{ b.total_price?.toLocaleString() }}
</p>
<p v-if="b.notes" class="text-xs text-gray-400 mt-1">備注{{ b.notes }}</p>
</div>
<div class="flex flex-col items-end gap-2 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>
<div class="flex gap-2">
<button v-if="b.status === 'pending'" @click="doAction(b, 'confirm')"
class="text-xs bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded-full transition">
確認
</button>
<button v-if="b.status === 'pending'" @click="doAction(b, 'reject')"
class="text-xs bg-red-500 hover:bg-red-400 text-white px-3 py-1 rounded-full transition">
拒絕
</button>
<button v-if="b.status === 'confirmed'" @click="doAction(b, 'cancel')"
class="text-xs text-orange-500 hover:text-orange-700 underline">
取消
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -66,6 +66,7 @@ onMounted(fetchOffers)
<th class="px-6 py-3 text-left">地點</th>
<th class="px-6 py-3 text-left">地區</th>
<th class="px-6 py-3 text-right">價格</th>
<th class="px-6 py-3 text-center">時段</th>
<th class="px-6 py-3 text-center">操作</th>
</tr>
</thead>
@@ -75,6 +76,12 @@ onMounted(fetchOffers)
<td class="px-6 py-4 text-gray-500">{{ offer.location }}</td>
<td class="px-6 py-4 text-gray-500">{{ offer.region }}</td>
<td class="px-6 py-4 text-right font-medium">NT$ {{ offer.price?.toLocaleString() }}</td>
<td class="px-6 py-4 text-center">
<RouterLink :to="`/coach/schedules?offer_id=${offer.id}`"
class="text-xs bg-ocean-50 hover:bg-ocean-100 text-ocean-700 px-3 py-1 rounded-lg transition font-medium">
管理時段
</RouterLink>
</td>
<td class="px-6 py-4 text-center">
<div class="flex justify-center gap-2">
<RouterLink :to="`/coach/offers/${offer.id}/edit`"
+4 -2
View File
@@ -70,10 +70,12 @@ async function submit() {
try {
if (isEdit.value) {
await coachApi.put(`/provider/offers/${route.params.id}`, payload)
router.push('/coach/dashboard')
} else {
await coachApi.post('/provider/offers', payload)
const res = await coachApi.post('/provider/offers', payload)
const newId = res.data.data?.id
router.push(`/coach/schedules?offer_id=${newId}&new=1`)
}
router.push('/coach/dashboard')
} catch (e) {
const data = e.response?.data
error.value = data?.message || '儲存失敗'
+5 -2
View File
@@ -76,13 +76,16 @@ async function submit() {
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">密碼 <span class="text-red-400">*</span></label>
<input v-model="form.password" type="password" required minlength="6"
<input v-model="form.password" type="password" required minlength="8"
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
<p class="text-xs text-gray-400 mt-1">至少 8 個字元</p>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">確認密碼 <span class="text-red-400">*</span></label>
<input v-model="form.password_confirmation" type="password" required
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400" />
class="w-full border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ocean-400"
:class="form.password_confirmation && form.password_confirmation !== form.password ? 'border-red-400' : 'border-gray-300'" />
<p v-if="form.password_confirmation && form.password_confirmation !== form.password" class="text-xs text-red-500 mt-1">密碼不一致</p>
</div>
</div>
@@ -0,0 +1,180 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { getSchedules, createSchedule, deleteSchedule } from '../../api/coachScheduleApi'
import coachApi from '../../api/coachAxios'
const route = useRoute()
const schedules = ref([])
const offers = ref([])
const loading = ref(true)
const showForm = ref(false)
const formError = ref('')
const isNewCourse = ref(route.query.new === '1')
const form = ref({
diving_offer_id: route.query.offer_id ? Number(route.query.offer_id) : '',
scheduled_date: '',
start_time: '',
max_participants: 1,
})
// 時間選擇器
const timePeriod = ref('AM')
const timeHour = ref('08')
const timeMinute = ref('00')
const HOURS_AM = ['06','07','08','09','10','11']
const HOURS_PM = ['12','13','14','15','16','17','18']
const MINUTES = ['00','30']
const hourOptions = computed(() => timePeriod.value === 'AM' ? HOURS_AM : HOURS_PM)
function syncTime() {
if (timePeriod.value === 'AM' && !HOURS_AM.includes(timeHour.value)) timeHour.value = '08'
if (timePeriod.value === 'PM' && !HOURS_PM.includes(timeHour.value)) timeHour.value = '13'
form.value.start_time = `${timeHour.value}:${timeMinute.value}`
}
watch([timePeriod, timeHour, timeMinute], syncTime, { immediate: true })
const today = computed(() => new Date().toISOString().split('T')[0])
onMounted(async () => {
try {
const [sRes, oRes] = await Promise.all([
getSchedules(),
coachApi.get('/provider/offers'),
])
schedules.value = sRes.data.data
offers.value = oRes.data.data
if (isNewCourse.value) showForm.value = true
} finally {
loading.value = false
}
})
async function submitForm() {
formError.value = ''
try {
const res = await createSchedule(form.value)
schedules.value.unshift(res.data.data)
showForm.value = false
form.value = { diving_offer_id: '', scheduled_date: '', start_time: '', max_participants: 1 }
} catch (e) {
formError.value = e.response?.data?.message || '建立失敗'
}
}
async function doDelete(schedule) {
if (!confirm(`確定取消「${schedule.offer_title} ${schedule.scheduled_date}」這個時段?\n該時段下的預約將自動取消。`)) return
try {
await deleteSchedule(schedule.id)
schedule.status = 'cancelled'
} catch (e) {
alert(e.response?.data?.message || '操作失敗')
}
}
const STATUS_COLOR = {
open: 'bg-green-100 text-green-700',
full: 'bg-yellow-100 text-yellow-700',
cancelled: 'bg-gray-100 text-gray-400',
}
</script>
<template>
<div class="p-6 max-w-4xl mx-auto">
<!-- 新建課程引導提示 -->
<div v-if="isNewCourse" class="bg-ocean-50 border border-ocean-200 rounded-xl px-5 py-4 mb-6 flex items-start gap-3">
<span class="text-2xl">🎉</span>
<div>
<p class="font-semibold text-ocean-800">課程建立成功</p>
<p class="text-sm text-ocean-700 mt-0.5">請為課程新增開課時段學員才能看到可預約的日期</p>
</div>
</div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-800">時段管理</h1>
<button
@click="showForm = !showForm"
class="bg-ocean-700 hover:bg-ocean-600 text-white px-5 py-2 rounded-full text-sm font-medium transition"
>
{{ showForm ? '取消' : '+ 新增時段' }}
</button>
</div>
<!-- 新增表單 -->
<div v-if="showForm" class="bg-ocean-50 rounded-2xl p-6 mb-6 border border-ocean-200">
<h2 class="font-semibold text-gray-700 mb-4">新增開課時段</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-600 mb-1">課程</label>
<select v-model="form.diving_offer_id" class="w-full border rounded-lg px-3 py-2 text-sm">
<option value="" disabled>請選擇課程</option>
<option v-for="o in offers" :key="o.id" :value="o.id">{{ o.title }}</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">日期</label>
<input v-model="form.scheduled_date" type="date" :min="today" class="w-full border rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">開始時間</label>
<div class="flex gap-2">
<select v-model="timePeriod" @change="syncTime"
class="border rounded-lg px-3 py-2 text-sm w-24 bg-white">
<option value="AM">上午</option>
<option value="PM">下午</option>
</select>
<select v-model="timeHour" @change="syncTime"
class="border rounded-lg px-3 py-2 text-sm flex-1 bg-white">
<option v-for="h in hourOptions" :key="h" :value="h">{{ h }} </option>
</select>
<select v-model="timeMinute" @change="syncTime"
class="border rounded-lg px-3 py-2 text-sm w-24 bg-white">
<option value="00">00 </option>
<option value="30">30 </option>
</select>
</div>
<p class="text-xs text-gray-400 mt-1">已選{{ form.start_time }}</p>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">人數上限</label>
<input v-model.number="form.max_participants" type="number" min="1" class="w-full border rounded-lg px-3 py-2 text-sm" />
</div>
</div>
<p v-if="formError" class="text-red-500 text-sm mt-3">{{ formError }}</p>
<button @click="submitForm" class="mt-4 bg-ocean-700 hover:bg-ocean-600 text-white px-6 py-2 rounded-full text-sm font-medium transition">
建立時段
</button>
</div>
<div v-if="loading" class="text-center text-gray-400 py-20">載入中...</div>
<div v-else-if="schedules.length === 0" class="text-center text-gray-400 py-20">尚未建立任何時段</div>
<div v-else class="space-y-3">
<div
v-for="s in schedules"
:key="s.id"
class="bg-white rounded-xl shadow px-5 py-4 flex items-center justify-between"
>
<div>
<p class="font-medium text-gray-800">{{ s.offer_title }}</p>
<p class="text-sm text-gray-500 mt-0.5">{{ s.scheduled_date }} {{ s.start_time }}剩餘 {{ s.remaining_spots }}/{{ s.max_participants }} </p>
</div>
<div class="flex items-center gap-3">
<span class="text-xs px-3 py-1 rounded-full font-medium" :class="STATUS_COLOR[s.status]">
{{ { open: '開放', full: '已滿', cancelled: '已取消' }[s.status] || s.status }}
</span>
<button
v-if="s.status !== 'cancelled'"
@click="doDelete(s)"
class="text-sm text-red-400 hover:text-red-600 underline"
>
取消時段
</button>
</div>
</div>
</div>
</div>
</template>