Files
CFDivePlatform/frontend/src/views/coach/BookingManagerView.vue
T
a620906209 81a9f84b26 feat:實作評價系統 — 匿名評價、有幫助投票、手動完成預約
後端:
- 新增 reviews / review_edits / review_votes migration(含索引)
- Review / ReviewEdit / ReviewVote Model
- ReviewController:評價 CRUD、資格驗證(completed booking)、rating 即時重算
- toggleHelpful:Member 限定、GREATEST 原子防負、DB transaction 同步
- AdminReviewController:全量列表、刪除(含重算)
- AdminBookingController:全量列表、手動標記 completed
- ProviderBookingController 新增 complete 方法(教練手動完成預約)
- DevelopmentSeeder:快速重建測試資料(admin/coach/member + offers + bookings)
- EnsureAdmin middleware 正式納入 bootstrap/app.php
- Nginx server_name 加入 cfdive.local

前端:
- 課程詳情頁加入評價區塊(星等分布、排序切換、撰寫/修改/刪除、有幫助 Toggle)
- Coach Portal 新增「課程評價」頁(只讀,依課程分組)
- Coach 預約管理加入「完成」按鈕
- Admin 新增「預約管理」頁(標記完成)、「評價管理」頁(刪除)
- Admin / Coach Navbar 新增對應連結

OpenSpec:
- review-system change 歸檔至 archive/2026-05-12-review-system
- 新增 specs/review-lifecycle 與 specs/review-voting 主規格
- review-voting spec 補充 Member 限定與 GREATEST 原子更新說明

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 02:46:54 +08:00

135 lines
5.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onMounted, computed } from 'vue'
import { getProviderBookings, confirmBooking, rejectBooking, cancelBooking, completeBooking } 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)
if (action === 'complete') await completeBooking(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, 'complete')"
class="text-xs bg-blue-600 hover:bg-blue-500 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>