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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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`"
|
||||
|
||||
@@ -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 || '儲存失敗'
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user