From 975b56ca54353fc5a4672b4243c83a6f2913f0bb Mon Sep 17 00:00:00 2001 From: Hank Date: Tue, 12 May 2026 00:24:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=AF=A6=E4=BD=9C=E9=A0=90=E7=B4=84?= =?UTF-8?q?=E7=B3=BB=E7=B5=B1=20=E2=80=94=20=E6=99=82=E6=AE=B5=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=80=81=E9=A0=90=E7=B4=84=E7=94=9F=E5=91=BD=E9=80=B1?= =?UTF-8?q?=E6=9C=9F=E8=88=87=E5=89=8D=E7=AB=AF=E6=95=B4=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 後端: - 新增 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) --- Dockerfile | 9 +- .../Commands/CompleteFinishedBookings.php | 24 ++ .../Commands/ExpirePendingBookings.php | 24 ++ app/Enums/BookingStatus.php | 14 + app/Enums/ScheduleStatus.php | 10 + .../API/MemberBookingController.php | 151 +++++++ .../API/ProviderBookingController.php | 120 ++++++ .../Controllers/API/ScheduleController.php | 131 ++++++ app/Models/Booking.php | 51 +++ app/Models/CourseSchedule.php | 54 +++ app/Models/DivingOffer.php | 5 + ...1_144005_create_course_schedules_table.php | 37 ++ ...026_05_11_144028_create_bookings_table.php | 38 ++ docker/php/docker-entrypoint.sh | 4 + frontend/src/api/bookingApi.js | 17 + frontend/src/api/coachBookingApi.js | 17 + frontend/src/api/coachScheduleApi.js | 17 + frontend/src/api/scheduleApi.js | 10 + frontend/src/components/CoachNavBar.vue | 6 +- frontend/src/components/NavBar.vue | 1 + frontend/src/router/index.js | 5 +- frontend/src/views/CourseDetailView.vue | 94 ++++- frontend/src/views/MyBookingsView.vue | 166 ++++++++ frontend/src/views/RegisterView.vue | 3 + .../src/views/coach/BookingManagerView.vue | 129 ++++++ frontend/src/views/coach/DashboardView.vue | 7 + frontend/src/views/coach/OfferFormView.vue | 6 +- frontend/src/views/coach/RegisterView.vue | 7 +- .../src/views/coach/ScheduleManagerView.vue | 180 ++++++++ .../2026-05-12-booking-system/.openspec.yaml | 2 + .../2026-05-12-booking-system/README.md | 3 + .../2026-05-12-booking-system/design.md | 385 ++++++++++++++++++ .../2026-05-12-booking-system/proposal.md | 42 ++ .../specs/booking-lifecycle/spec.md | 104 +++++ .../specs/course-scheduling/spec.md | 72 ++++ .../2026-05-12-booking-system/tasks.md | 77 ++++ openspec/specs/booking-lifecycle/spec.md | 102 +++++ openspec/specs/course-scheduling/spec.md | 70 ++++ routes/api.php | 22 +- routes/console.php | 4 + 40 files changed, 2202 insertions(+), 18 deletions(-) create mode 100644 app/Console/Commands/CompleteFinishedBookings.php create mode 100644 app/Console/Commands/ExpirePendingBookings.php create mode 100644 app/Enums/BookingStatus.php create mode 100644 app/Enums/ScheduleStatus.php create mode 100644 app/Http/Controllers/API/MemberBookingController.php create mode 100644 app/Http/Controllers/API/ProviderBookingController.php create mode 100644 app/Http/Controllers/API/ScheduleController.php create mode 100644 app/Models/Booking.php create mode 100644 app/Models/CourseSchedule.php create mode 100644 database/migrations/2026_05_11_144005_create_course_schedules_table.php create mode 100644 database/migrations/2026_05_11_144028_create_bookings_table.php create mode 100644 frontend/src/api/bookingApi.js create mode 100644 frontend/src/api/coachBookingApi.js create mode 100644 frontend/src/api/coachScheduleApi.js create mode 100644 frontend/src/api/scheduleApi.js create mode 100644 frontend/src/views/MyBookingsView.vue create mode 100644 frontend/src/views/coach/BookingManagerView.vue create mode 100644 frontend/src/views/coach/ScheduleManagerView.vue create mode 100644 openspec/changes/archive/2026-05-12-booking-system/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-12-booking-system/README.md create mode 100644 openspec/changes/archive/2026-05-12-booking-system/design.md create mode 100644 openspec/changes/archive/2026-05-12-booking-system/proposal.md create mode 100644 openspec/changes/archive/2026-05-12-booking-system/specs/booking-lifecycle/spec.md create mode 100644 openspec/changes/archive/2026-05-12-booking-system/specs/course-scheduling/spec.md create mode 100644 openspec/changes/archive/2026-05-12-booking-system/tasks.md create mode 100644 openspec/specs/booking-lifecycle/spec.md create mode 100644 openspec/specs/course-scheduling/spec.md diff --git a/Dockerfile b/Dockerfile index 4854636..1cd5bc4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,8 @@ RUN apt-get update && apt-get install -y \ libzip-dev \ default-mysql-client \ netcat-openbsd \ - grep + grep \ + cron # 清理 apt 快取以減小鏡像大小 RUN apt-get clean && rm -rf /var/lib/apt/lists/* @@ -48,6 +49,12 @@ COPY docker/php/local.ini /usr/local/etc/php/conf.d/local.ini COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh +# 加入 Laravel Scheduler cron job +RUN echo "* * * * * www-data php /var/www/artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1" \ + > /etc/cron.d/laravel-scheduler \ + && chmod 0644 /etc/cron.d/laravel-scheduler \ + && crontab /etc/cron.d/laravel-scheduler + # 設置容器啟動時執行的入口點 # 這將在 CMD 指令之前執行 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/app/Console/Commands/CompleteFinishedBookings.php b/app/Console/Commands/CompleteFinishedBookings.php new file mode 100644 index 0000000..3ae414b --- /dev/null +++ b/app/Console/Commands/CompleteFinishedBookings.php @@ -0,0 +1,24 @@ +value) + ->whereHas('schedule', fn($q) => $q->whereDate('scheduled_date', '<', now()->toDateString())) + ->update(['status' => BookingStatus::Completed->value]); + + Log::info("CompleteFinishedBookings: {$count} completed"); + $this->info("CompleteFinishedBookings: {$count} bookings completed."); + } +} diff --git a/app/Console/Commands/ExpirePendingBookings.php b/app/Console/Commands/ExpirePendingBookings.php new file mode 100644 index 0000000..649a564 --- /dev/null +++ b/app/Console/Commands/ExpirePendingBookings.php @@ -0,0 +1,24 @@ +value) + ->where('created_at', '<=', now()->subHours(48)) + ->update(['status' => BookingStatus::Expired->value]); + + Log::info("ExpirePendingBookings: {$count} expired"); + $this->info("ExpirePendingBookings: {$count} bookings expired."); + } +} diff --git a/app/Enums/BookingStatus.php b/app/Enums/BookingStatus.php new file mode 100644 index 0000000..5bf4ebd --- /dev/null +++ b/app/Enums/BookingStatus.php @@ -0,0 +1,14 @@ +where('member_id', $request->user()->id) + ->orderByDesc('created_at') + ->get() + ->map(fn($b) => $this->formatBooking($b)); + + return response()->json(['status' => true, 'data' => $bookings]); + } + + public function show(Request $request, int $id) + { + $booking = Booking::with(['schedule.divingOffer'])->findOrFail($id); + if ($booking->member_id !== $request->user()->id) { + return response()->json(['status' => false, 'message' => '無權查看此預約'], 403); + } + + return response()->json(['status' => true, 'data' => $this->formatBooking($booking)]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'schedule_id' => 'required|integer|exists:course_schedules,id', + 'participants' => 'required|integer|min:1', + 'notes' => 'nullable|string|max:500', + ]); + + $schedule = CourseSchedule::with('divingOffer')->findOrFail($data['schedule_id']); + + // Layer 1:快速失敗 + if ($schedule->status !== ScheduleStatus::Open) { + return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422); + } + if ($data['participants'] > $schedule->remainingSpots()) { + return response()->json(['status' => false, 'message' => '人數超過剩餘名額'], 422); + } + + $memberId = $request->user()->id; + + try { + $booking = DB::transaction(function () use ($data, $schedule, $memberId) { + // Layer 2:lockForUpdate 後二次驗證 + $schedule = CourseSchedule::lockForUpdate()->find($schedule->id); + if ($data['participants'] > $schedule->remainingSpots()) { + throw new \RuntimeException('名額不足,請重新選擇'); + } + + // 重複預約檢查 + $duplicate = Booking::where('member_id', $memberId) + ->where('schedule_id', $schedule->id) + ->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value]) + ->exists(); + if ($duplicate) { + throw new \RuntimeException('您已預約此時段'); + } + + return Booking::create([ + 'schedule_id' => $schedule->id, + 'member_id' => $memberId, + 'participants' => $data['participants'], + 'total_price' => $schedule->divingOffer->price * $data['participants'], + 'status' => BookingStatus::Pending, + 'notes' => $data['notes'] ?? null, + ]); + }); + } catch (\RuntimeException $e) { + return response()->json(['status' => false, 'message' => $e->getMessage()], 422); + } + + return response()->json([ + 'status' => true, + 'message' => '預約已送出,等待教練確認', + 'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])), + ], 201); + } + + public function destroy(Request $request, int $id) + { + $booking = Booking::with('schedule')->findOrFail($id); + if ($booking->member_id !== $request->user()->id) { + return response()->json(['status' => false, 'message' => '無權操作此預約'], 403); + } + + $canCancelFrom = [BookingStatus::Pending, BookingStatus::Confirmed]; + if (!in_array($booking->status, $canCancelFrom)) { + return response()->json(['status' => false, 'message' => '此預約狀態無法取消'], 422); + } + + // 24h 截止驗證 + $schedule = $booking->schedule; + $courseStart = Carbon::parse($schedule->scheduled_date->toDateString() . ' ' . $schedule->start_time); + if (now()->diffInHours($courseStart, false) < 24) { + return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消,請聯繫教練'], 422); + } + + DB::transaction(function () use ($booking, $schedule) { + $wasConfirmed = $booking->status === BookingStatus::Confirmed; + $booking->update(['status' => BookingStatus::MemberCancelled]); + + if ($wasConfirmed) { + $schedule = $booking->schedule()->lockForUpdate()->first(); + $schedule->decrement('current_participants', $booking->participants); + $schedule->refresh(); + + if ($schedule->current_participants < $schedule->max_participants + && $schedule->status === ScheduleStatus::Full) { + $schedule->update(['status' => ScheduleStatus::Open]); + } + } + }); + + return response()->json(['status' => true, 'message' => '預約已取消']); + } + + private function formatBooking(Booking $b): array + { + $offer = $b->schedule?->divingOffer; + return [ + 'id' => $b->id, + 'offer_id' => $offer?->id, + 'offer_title' => $offer?->title, + 'offer_location' => $offer?->location, + 'offer_region' => $offer?->region, + 'offer_price' => $offer?->price, + 'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(), + 'start_time' => $b->schedule?->start_time, + 'participants' => $b->participants, + 'total_price' => $b->total_price, + 'status' => $b->status->value, + 'notes' => $b->notes, + 'created_at' => $b->created_at?->toISOString(), + ]; + } +} diff --git a/app/Http/Controllers/API/ProviderBookingController.php b/app/Http/Controllers/API/ProviderBookingController.php new file mode 100644 index 0000000..1adef81 --- /dev/null +++ b/app/Http/Controllers/API/ProviderBookingController.php @@ -0,0 +1,120 @@ +user(); + $bookings = Booking::with(['member', 'schedule.divingOffer']) + ->whereHas('schedule', fn($q) => $q->where('provider_id', $provider->id)) + ->orderByDesc('created_at') + ->get() + ->map(fn($b) => $this->formatBooking($b)); + + return response()->json(['status' => true, 'data' => $bookings]); + } + + public function confirm(Request $request, int $id) + { + $booking = Booking::with('schedule')->findOrFail($id); + $this->authorizeProvider($request, $booking); + + if (!$booking->canTransitionTo(BookingStatus::Confirmed)) { + return response()->json(['status' => false, 'message' => '當前狀態無法確認'], 422); + } + + try { + DB::transaction(function () use ($booking) { + $schedule = $booking->schedule()->lockForUpdate()->first(); + $remaining = $schedule->max_participants - $schedule->current_participants; + + if ($booking->participants > $remaining) { + throw new \RuntimeException('名額不足,無法確認此預約'); + } + + $booking->update(['status' => BookingStatus::Confirmed]); + $schedule->increment('current_participants', $booking->participants); + $schedule->refresh(); + + if ($schedule->current_participants >= $schedule->max_participants) { + $schedule->update(['status' => ScheduleStatus::Full]); + } + }); + } catch (\RuntimeException $e) { + return response()->json(['status' => false, 'message' => $e->getMessage()], 422); + } + + return response()->json(['status' => true, 'message' => '預約已確認', 'data' => $this->formatBooking($booking->fresh(['member', 'schedule.divingOffer']))]); + } + + public function reject(Request $request, int $id) + { + $booking = Booking::with('schedule')->findOrFail($id); + $this->authorizeProvider($request, $booking); + + if (!$booking->canTransitionTo(BookingStatus::Rejected)) { + return response()->json(['status' => false, 'message' => '當前狀態無法拒絕'], 422); + } + + $booking->update(['status' => BookingStatus::Rejected]); + + return response()->json(['status' => true, 'message' => '預約已拒絕']); + } + + public function cancel(Request $request, int $id) + { + $booking = Booking::with('schedule')->findOrFail($id); + $this->authorizeProvider($request, $booking); + + if (!$booking->canTransitionTo(BookingStatus::ProviderCancelled)) { + return response()->json(['status' => false, 'message' => '當前狀態無法取消'], 422); + } + + DB::transaction(function () use ($booking) { + $schedule = $booking->schedule()->lockForUpdate()->first(); + $booking->update(['status' => BookingStatus::ProviderCancelled]); + $schedule->decrement('current_participants', $booking->participants); + $schedule->refresh(); + + if ($schedule->current_participants < $schedule->max_participants + && $schedule->status === ScheduleStatus::Full) { + $schedule->update(['status' => ScheduleStatus::Open]); + } + }); + + return response()->json(['status' => true, 'message' => '預約已取消']); + } + + private function authorizeProvider(Request $request, Booking $booking): void + { + if ($booking->schedule->provider_id !== $request->user()->id) { + abort(403, '無權操作此預約'); + } + } + + private function formatBooking(Booking $b): array + { + return [ + 'id' => $b->id, + 'member_name' => $b->member?->name, + 'member_email' => $b->member?->email, + 'offer_title' => $b->schedule?->divingOffer?->title, + 'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(), + 'start_time' => $b->schedule?->start_time, + 'participants' => $b->participants, + 'total_price' => $b->total_price, + 'status' => $b->status->value, + 'notes' => $b->notes, + 'created_at' => $b->created_at?->toISOString(), + ]; + } +} diff --git a/app/Http/Controllers/API/ScheduleController.php b/app/Http/Controllers/API/ScheduleController.php new file mode 100644 index 0000000..c6142ea --- /dev/null +++ b/app/Http/Controllers/API/ScheduleController.php @@ -0,0 +1,131 @@ +user(); + $schedules = CourseSchedule::with('divingOffer') + ->where('provider_id', $provider->id) + ->orderBy('scheduled_date') + ->orderBy('start_time') + ->get() + ->map(fn($s) => $this->formatSchedule($s)); + + return response()->json(['status' => true, 'data' => $schedules]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'diving_offer_id' => 'required|integer|exists:diving_offers,id', + 'scheduled_date' => 'required|date|after_or_equal:today', + 'start_time' => 'required|date_format:H:i', + 'max_participants' => 'required|integer|min:1', + ]); + + $offer = DivingOffer::findOrFail($data['diving_offer_id']); + if ($offer->provider_id !== $request->user()->id) { + return response()->json(['status' => false, 'message' => '無權操作此課程'], 403); + } + + $schedule = CourseSchedule::create([ + 'diving_offer_id' => $data['diving_offer_id'], + 'provider_id' => $request->user()->id, + 'scheduled_date' => $data['scheduled_date'], + 'start_time' => $data['start_time'], + 'max_participants' => $data['max_participants'], + 'current_participants'=> 0, + 'status' => ScheduleStatus::Open, + ]); + + return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule)], 201); + } + + public function update(Request $request, int $id) + { + $schedule = CourseSchedule::findOrFail($id); + if ($schedule->provider_id !== $request->user()->id) { + return response()->json(['status' => false, 'message' => '無權操作此時段'], 403); + } + + $data = $request->validate([ + 'start_time' => 'sometimes|date_format:H:i', + 'max_participants' => 'sometimes|integer|min:1', + ]); + + if (isset($data['max_participants']) && $data['max_participants'] < $schedule->current_participants) { + return response()->json([ + 'status' => false, + 'message' => '人數上限不可低於目前已確認人數(' . $schedule->current_participants . ')', + ], 422); + } + + $schedule->update($data); + + return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule->fresh())]); + } + + public function destroy(Request $request, int $id) + { + $schedule = CourseSchedule::findOrFail($id); + if ($schedule->provider_id !== $request->user()->id) { + return response()->json(['status' => false, 'message' => '無權操作此時段'], 403); + } + + DB::transaction(function () use ($schedule) { + $schedule->update(['status' => ScheduleStatus::Cancelled]); + $schedule->bookings() + ->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value]) + ->update(['status' => BookingStatus::ProviderCancelled->value]); + }); + + return response()->json(['status' => true, 'message' => '時段已取消']); + } + + public function publicList(int $offerId) + { + $offer = DivingOffer::findOrFail($offerId); + $schedules = CourseSchedule::where('diving_offer_id', $offer->id) + ->where('status', ScheduleStatus::Open->value) + ->whereDate('scheduled_date', '>=', now()->toDateString()) + ->orderBy('scheduled_date') + ->orderBy('start_time') + ->get() + ->map(fn($s) => [ + 'id' => $s->id, + 'scheduled_date' => $s->scheduled_date->toDateString(), + 'start_time' => $s->start_time, + 'max_participants' => $s->max_participants, + 'remaining_spots' => $s->remainingSpots(), + 'status' => $s->status->value, + ]); + + return response()->json(['status' => true, 'data' => $schedules]); + } + + private function formatSchedule(CourseSchedule $s): array + { + return [ + 'id' => $s->id, + 'diving_offer_id' => $s->diving_offer_id, + 'offer_title' => $s->divingOffer?->title, + 'scheduled_date' => $s->scheduled_date?->toDateString(), + 'start_time' => $s->start_time, + 'max_participants' => $s->max_participants, + 'current_participants' => $s->current_participants, + 'remaining_spots' => $s->remainingSpots(), + 'status' => $s->status->value, + ]; + } +} diff --git a/app/Models/Booking.php b/app/Models/Booking.php new file mode 100644 index 0000000..f6644ed --- /dev/null +++ b/app/Models/Booking.php @@ -0,0 +1,51 @@ + 'integer', + 'total_price' => 'integer', + 'status' => BookingStatus::class, + ]; + + const VALID_TRANSITIONS = [ + 'pending' => ['confirmed', 'rejected', 'expired', 'member_cancelled'], + 'confirmed' => ['completed', 'member_cancelled', 'provider_cancelled'], + 'completed' => [], + 'rejected' => [], + 'expired' => [], + 'member_cancelled' => [], + 'provider_cancelled' => [], + ]; + + public function canTransitionTo(BookingStatus $newStatus): bool + { + $current = $this->status->value; + $allowed = self::VALID_TRANSITIONS[$current] ?? []; + return in_array($newStatus->value, $allowed); + } + + public function schedule() + { + return $this->belongsTo(CourseSchedule::class, 'schedule_id'); + } + + public function member() + { + return $this->belongsTo(User::class, 'member_id'); + } +} diff --git a/app/Models/CourseSchedule.php b/app/Models/CourseSchedule.php new file mode 100644 index 0000000..2d39181 --- /dev/null +++ b/app/Models/CourseSchedule.php @@ -0,0 +1,54 @@ + 'date', + 'max_participants' => 'integer', + 'current_participants' => 'integer', + 'status' => ScheduleStatus::class, + ]; + + public function divingOffer() + { + return $this->belongsTo(DivingOffer::class); + } + + public function provider() + { + return $this->belongsTo(User::class, 'provider_id'); + } + + public function bookings() + { + return $this->hasMany(Booking::class, 'schedule_id'); + } + + public function remainingSpots(): int + { + return max(0, $this->max_participants - $this->current_participants); + } + + protected function startTime(): Attribute + { + return Attribute::make( + get: fn($value) => $value ? substr($value, 0, 5) : $value, + ); + } +} diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php index d5fbe6d..e10047f 100644 --- a/app/Models/DivingOffer.php +++ b/app/Models/DivingOffer.php @@ -30,4 +30,9 @@ class DivingOffer extends Model 'price' => 'integer', 'reviews'=> 'integer', ]; + + public function schedules() + { + return $this->hasMany(CourseSchedule::class, 'diving_offer_id'); + } } diff --git a/database/migrations/2026_05_11_144005_create_course_schedules_table.php b/database/migrations/2026_05_11_144005_create_course_schedules_table.php new file mode 100644 index 0000000..92e5e6c --- /dev/null +++ b/database/migrations/2026_05_11_144005_create_course_schedules_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete(); + $table->foreignId('provider_id')->constrained('users')->cascadeOnDelete(); + $table->date('scheduled_date'); + $table->time('start_time'); + $table->unsignedInteger('max_participants'); + $table->unsignedInteger('current_participants')->default(0); + $table->string('status')->default('open'); + $table->timestamps(); + + $table->index(['diving_offer_id', 'status', 'scheduled_date'], 'idx_offer_status_date'); + $table->index('provider_id', 'idx_provider_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('course_schedules'); + } +}; diff --git a/database/migrations/2026_05_11_144028_create_bookings_table.php b/database/migrations/2026_05_11_144028_create_bookings_table.php new file mode 100644 index 0000000..48e5495 --- /dev/null +++ b/database/migrations/2026_05_11_144028_create_bookings_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('schedule_id')->constrained('course_schedules')->cascadeOnDelete(); + $table->foreignId('member_id')->constrained('users')->cascadeOnDelete(); + $table->unsignedInteger('participants')->default(1); + $table->unsignedInteger('total_price'); + $table->string('status')->default('pending'); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['member_id', 'status'], 'idx_member_status'); + $table->index(['schedule_id', 'status'], 'idx_schedule_status'); + $table->index(['status', 'created_at'], 'idx_status_created_at'); + $table->index(['status', 'schedule_id'], 'idx_status_sched'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bookings'); + } +}; diff --git a/docker/php/docker-entrypoint.sh b/docker/php/docker-entrypoint.sh index 26d2eac..e2ffe9e 100644 --- a/docker/php/docker-entrypoint.sh +++ b/docker/php/docker-entrypoint.sh @@ -110,5 +110,9 @@ fi echo "✅ CFDivePlatform 初始化完成!" +# 啟動 cron daemon(Laravel Scheduler) +echo "⏰ 啟動 Laravel Scheduler cron..." +service cron start || cron || true + # 執行傳入的命令 exec "$@" diff --git a/frontend/src/api/bookingApi.js b/frontend/src/api/bookingApi.js new file mode 100644 index 0000000..bdd29e7 --- /dev/null +++ b/frontend/src/api/bookingApi.js @@ -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}`) +} diff --git a/frontend/src/api/coachBookingApi.js b/frontend/src/api/coachBookingApi.js new file mode 100644 index 0000000..39a2ab4 --- /dev/null +++ b/frontend/src/api/coachBookingApi.js @@ -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`) +} diff --git a/frontend/src/api/coachScheduleApi.js b/frontend/src/api/coachScheduleApi.js new file mode 100644 index 0000000..202d96d --- /dev/null +++ b/frontend/src/api/coachScheduleApi.js @@ -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}`) +} diff --git a/frontend/src/api/scheduleApi.js b/frontend/src/api/scheduleApi.js new file mode 100644 index 0000000..d4155d9 --- /dev/null +++ b/frontend/src/api/scheduleApi.js @@ -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`) +} diff --git a/frontend/src/components/CoachNavBar.vue b/frontend/src/components/CoachNavBar.vue index 6dc72c6..1b866a4 100644 --- a/frontend/src/components/CoachNavBar.vue +++ b/frontend/src/components/CoachNavBar.vue @@ -18,8 +18,10 @@ async function handleLogout() { 🤿 Coach Portal - 我的課程 - 個人資料 + 我的課程 + 時段管理 + 預約管理 + 個人資料
diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue index 1cdfab0..4287af1 100644 --- a/frontend/src/components/NavBar.vue +++ b/frontend/src/components/NavBar.vue @@ -25,6 +25,7 @@ async function handleLogout() { + 我的預約 個人資料
@@ -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' : ''" /> +

密碼不一致