Jam buka tutup sudah dinamis, booking sbg user sudah bisa, booking sbg admin sudah bisa

This commit is contained in:
Stephen Gesityan 2025-06-04 15:58:06 +07:00
parent b8f70e7f6f
commit 0580940cf5
5 changed files with 315 additions and 221 deletions

View File

@ -23,179 +23,253 @@ public function __construct(MidtransService $midtransService)
} }
// Tambahkan method baru untuk booking langsung oleh admin // Tambahkan method baru untuk booking langsung oleh admin
public function adminDirectBooking(Request $request) { public function adminDirectBooking($request) {
try { try {
$request->validate([ // Handle both Request object dan Collection
'table_id' => 'required|exists:tables,id', $data = $request instanceof \Illuminate\Http\Request ? $request->all() : $request->toArray();
'start_time' => 'required|date',
'end_time' => 'required|date|after:start_time',
]);
$user = Auth::user();
// Validasi bahwa user adalah admin dan mengelola venue dari meja tersebut
$table = Table::findOrFail($request->table_id);
if ($user->role !== 'admin' || $user->venue_id !== $table->venue_id) {
return response()->json([
'message' => 'Unauthorized action'
], 403);
}
// Cek konflik booking
$conflict = Booking::where('table_id', $request->table_id)
->where(function($query) use ($request) {
$query->whereBetween('start_time', [$request->start_time, $request->end_time])
->orWhere(function($query) use ($request) {
$query->where('start_time', '<', $request->start_time)
->where('end_time', '>', $request->start_time);
});
})
->where('status', 'paid')
->exists();
if ($conflict) {
return response()->json(['message' => 'Meja sudah dibooking di jam tersebut'], 409);
}
// Hitung total biaya (meskipun admin tidak membayar, kita tetap catat nilainya)
$startTime = Carbon::parse($request->start_time);
$endTime = Carbon::parse($request->end_time);
$duration = $endTime->diffInHours($startTime);
$totalAmount = $duration * $table->price_per_hour;
// Generate order ID unik untuk admin
$adminOrderId = 'ADMIN-' . $user->id . '-' . time();
// Buat booking langsung dengan status paid
$booking = Booking::create([
'table_id' => $request->table_id,
'user_id' => $user->id,
'start_time' => $request->start_time,
'end_time' => $request->end_time,
'status' => 'paid', // langsung set sebagai paid
'total_amount' => $totalAmount,
'payment_id' => null, // Admin tidak perlu payment_id
'payment_method' => 'admin_direct', // Tandai sebagai booking langsung admin
'order_id' => $adminOrderId,
]);
// Update table status menjadi Booked
$table->update(['status' => 'Booked']);
// Validasi manual karena bisa dari collection
if (!isset($data['table_id']) || !isset($data['start_time']) || !isset($data['end_time'])) {
return response()->json([ return response()->json([
'message' => 'Booking created successfully', 'message' => 'Missing required fields'
'booking_id' => $booking->id ], 400);
]);
} catch (\Exception $e) {
\Log::error('Admin direct booking error:', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return response()->json([
'message' => 'Failed to create booking: ' . $e->getMessage()
], 500);
} }
$user = Auth::user();
// Validasi bahwa user adalah admin dan mengelola venue dari meja tersebut
$table = Table::findOrFail($data['table_id']);
if ($user->role !== 'admin' || $user->venue_id !== $table->venue_id) {
return response()->json([
'message' => 'Unauthorized action'
], 403);
}
// Parse start_time dan end_time yang sudah dalam format datetime string
$startDateTime = Carbon::parse($data['start_time']);
$endDateTime = Carbon::parse($data['end_time']);
// Validasi jam operasional venue (opsional, karena sudah divalidasi di createPaymentIntent)
$venue = $table->venue;
$venueOpenTime = Carbon::parse($venue->open_time);
$venueCloseTime = Carbon::parse($venue->close_time);
$startTimeOnly = $startDateTime->format('H:i');
$endTimeOnly = $endDateTime->format('H:i');
if ($startTimeOnly < $venueOpenTime->format('H:i') || $endTimeOnly > $venueCloseTime->format('H:i')) {
return response()->json([
'message' => 'Waktu booking di luar jam operasional venue'
], 400);
}
// Cek konflik booking
$conflict = Booking::where('table_id', $data['table_id'])
->whereDate('start_time', $startDateTime->format('Y-m-d'))
->where(function($query) use ($startDateTime, $endDateTime) {
$query->where(function($q) use ($startDateTime, $endDateTime) {
// Case 1: Booking baru mulai di tengah booking yang ada
$q->where('start_time', '<=', $startDateTime)
->where('end_time', '>', $startDateTime);
})->orWhere(function($q) use ($startDateTime, $endDateTime) {
// Case 2: Booking baru berakhir di tengah booking yang ada
$q->where('start_time', '<', $endDateTime)
->where('end_time', '>=', $endDateTime);
})->orWhere(function($q) use ($startDateTime, $endDateTime) {
// Case 3: Booking baru mencakup seluruh booking yang ada
$q->where('start_time', '>=', $startDateTime)
->where('end_time', '<=', $endDateTime);
});
})
->whereIn('status', ['paid', 'pending'])
->exists();
if ($conflict) {
return response()->json([
'message' => 'Meja sudah dibooking di jam tersebut'
], 409);
}
// Hitung total biaya dan durasi
$duration = $endDateTime->diffInHours($startDateTime);
$totalAmount = $duration * $table->price_per_hour;
// Generate order ID unik untuk admin
$adminOrderId = 'ADMIN-' . $user->id . '-' . time();
// Buat booking langsung dengan status paid
$booking = Booking::create([
'table_id' => $data['table_id'],
'user_id' => $user->id,
'start_time' => $startDateTime,
'end_time' => $endDateTime,
'status' => 'paid',
'total_amount' => $totalAmount,
'payment_id' => null,
'payment_method' => 'admin_direct',
'order_id' => $adminOrderId,
]);
return response()->json([
'success' => true,
'message' => 'Booking berhasil dibuat oleh admin',
'booking_id' => $booking->id,
'booking_details' => [
'table_name' => $table->name,
'start_time' => $startDateTime->format('Y-m-d H:i:s'),
'end_time' => $endDateTime->format('Y-m-d H:i:s'),
'duration' => $duration . ' jam',
'total_amount' => 'Rp ' . number_format($totalAmount, 0, ',', '.')
]
]);
} catch (\Exception $e) {
\Log::error('Admin direct booking error:', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'request_data' => $request instanceof \Illuminate\Http\Request ? $request->all() : $request->toArray()
]);
return response()->json([
'success' => false,
'message' => 'Gagal membuat booking: ' . $e->getMessage()
], 500);
} }
}
public function createPaymentIntent(Request $request) { public function createPaymentIntent(Request $request) {
try { try {
$request->validate([ $request->validate([
'table_id' => 'required|exists:tables,id', 'table_id' => 'required|exists:tables,id',
'start_time' => 'required|date', 'start_time' => 'required', // Ubah dari date menjadi string untuk format H:i
'end_time' => 'required|date|after:start_time', 'duration' => 'required|integer|min:1|max:12', // Validasi durasi
]); 'booking_date' => 'required|date_format:Y-m-d', // Validasi tanggal booking
]);
$user = Auth::user(); $user = Auth::user();
$table = Table::findOrFail($request->table_id); $table = Table::with('venue')->findOrFail($request->table_id);
if ($user->role === 'admin' && $user->venue_id === $table->venue_id) { // Buat datetime lengkap dari booking_date dan start_time
return $this->adminDirectBooking($request); $bookingDate = $request->booking_date;
} $startTime = $request->start_time; // Format H:i (contoh: "14:00")
$duration = (int) $request->duration;
// Cek apakah meja sedang dibooking pada waktu tersebut (hanya yang sudah paid) // Gabungkan tanggal dan waktu untuk membuat datetime lengkap
$conflict = Booking::where('table_id', $request->table_id) $startDateTime = Carbon::createFromFormat('Y-m-d H:i', $bookingDate . ' ' . $startTime, 'Asia/Jakarta');
->where(function($query) use ($request) { $endDateTime = $startDateTime->copy()->addHours($duration);
$query->whereBetween('start_time', [$request->start_time, $request->end_time])
->orWhere(function($query) use ($request) {
$query->where('start_time', '<', $request->start_time)
->where('end_time', '>', $request->start_time);
});
})
->where('status', 'paid') // Hanya cek yang sudah paid
->exists();
if ($conflict) { // Validasi waktu booking dalam jam operasional venue
return response()->json(['message' => 'Meja sudah dibooking di jam tersebut'], 409); $venueOpenTime = Carbon::createFromFormat('H:i:s', $table->venue->open_time)->format('H:i');
} $venueCloseTime = Carbon::createFromFormat('H:i:s', $table->venue->close_time)->format('H:i');
// Hitung total biaya
$table = Table::findOrFail($request->table_id);
$startTime = Carbon::parse($request->start_time);
$endTime = Carbon::parse($request->end_time);
$duration = $endTime->diffInHours($startTime);
$totalAmount = $duration * $table->price_per_hour;
// Simpan data booking sementara di session untuk digunakan setelah pembayaran
Session::put('temp_booking', [
'table_id' => $request->table_id,
'user_id' => Auth::id(),
'start_time' => $request->start_time,
'end_time' => $request->end_time,
'total_amount' => $totalAmount,
'created_at' => now(),
]);
// Generate unique order ID
$tempOrderId = 'TEMP-' . Auth::id() . '-' . time();
Session::put('temp_order_id', $tempOrderId);
// Simpan booking sementara ke database untuk bisa dilanjutkan nanti
PendingBooking::updateOrCreate(
[
'user_id' => Auth::id(),
'table_id' => $request->table_id,
'start_time' => $request->start_time
],
[
'end_time' => $request->end_time,
'total_amount' => $totalAmount,
'order_id' => $tempOrderId,
'expired_at' => now()->addHours(24), // Kadaluarsa dalam 24 jam
]
);
// Dapatkan snap token dari Midtrans tanpa menyimpan booking
$snapToken = $this->midtransService->createTemporaryTransaction($table, $totalAmount, $tempOrderId, Auth::user());
if (!$snapToken) {
throw new \Exception('Failed to get snap token from Midtrans');
}
\Log::info('Payment intent created successfully:', [
'order_id' => $tempOrderId,
'snap_token' => $snapToken
]);
if ($startTime < $venueOpenTime || $startTime >= $venueCloseTime) {
return response()->json([ return response()->json([
'message' => 'Payment intent created, proceed to payment', 'success' => false,
'total_amount' => $totalAmount, 'message' => 'Waktu booking di luar jam operasional venue'
'snap_token' => $snapToken, ], 422);
'order_id' => $tempOrderId
]);
} catch (\Exception $e) {
\Log::error('Payment intent error:', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return response()->json([
'message' => 'Gagal membuat transaksi: ' . $e->getMessage()
], 500);
} }
// Validasi bahwa end time tidak melebihi jam tutup venue
if ($endDateTime->format('H:i') > $venueCloseTime) {
return response()->json([
'success' => false,
'message' => 'Durasi booking melebihi jam tutup venue'
], 422);
}
// Cek untuk admin direct booking
if ($user->role === 'admin' && $user->venue_id === $table->venue_id) {
return $this->adminDirectBooking(collect([
'table_id' => $request->table_id,
'start_time' => $startDateTime->toDateTimeString(),
'end_time' => $endDateTime->toDateTimeString(),
]));
}
// Cek konflik booking dengan format datetime lengkap
$conflict = Booking::where('table_id', $request->table_id)
->where(function($query) use ($startDateTime, $endDateTime) {
$query->where(function($q) use ($startDateTime, $endDateTime) {
$q->where('start_time', '<', $endDateTime)
->where('end_time', '>', $startDateTime);
});
})
->where('status', 'paid')
->exists();
if ($conflict) {
return response()->json([
'success' => false,
'message' => 'Meja sudah dibooking di jam tersebut'
], 409);
}
// Hitung total biaya
$totalAmount = $duration * $table->price_per_hour;
// Simpan data booking sementara di session
Session::put('temp_booking', [
'table_id' => $request->table_id,
'user_id' => Auth::id(),
'start_time' => $startDateTime->toDateTimeString(),
'end_time' => $endDateTime->toDateTimeString(),
'total_amount' => $totalAmount,
'created_at' => now(),
]);
// Generate unique order ID
$tempOrderId = 'TEMP-' . Auth::id() . '-' . time();
Session::put('temp_order_id', $tempOrderId);
// Simpan booking sementara ke database
PendingBooking::updateOrCreate(
[
'user_id' => Auth::id(),
'table_id' => $request->table_id,
'start_time' => $startDateTime->toDateTimeString()
],
[
'end_time' => $endDateTime->toDateTimeString(),
'total_amount' => $totalAmount,
'order_id' => $tempOrderId,
'expired_at' => now()->addHours(24),
]
);
// Dapatkan snap token dari Midtrans
$snapToken = $this->midtransService->createTemporaryTransaction($table, $totalAmount, $tempOrderId, Auth::user());
if (!$snapToken) {
throw new \Exception('Failed to get snap token from Midtrans');
}
\Log::info('Payment intent created successfully:', [
'order_id' => $tempOrderId,
'snap_token' => $snapToken,
'start_time' => $startDateTime->toDateTimeString(),
'end_time' => $endDateTime->toDateTimeString()
]);
return response()->json([
'success' => true,
'message' => 'Payment intent created, proceed to payment',
'total_amount' => $totalAmount,
'snap_token' => $snapToken,
'order_id' => $tempOrderId
]);
} catch (\Exception $e) {
\Log::error('Payment intent error:', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return response()->json([
'success' => false,
'message' => 'Gagal membuat transaksi: ' . $e->getMessage()
], 500);
} }
}
public function store(Request $request) { public function store(Request $request) {
try { try {

View File

@ -52,10 +52,15 @@ class="w-full py-3 md:px-6 rounded-lg text-sm bg-primary text-white font-semibol
<a href="{{ route('venue', ['venueName' => $venue->name]) }}" <a href="{{ route('venue', ['venueName' => $venue->name]) }}"
class="flex flex-col h-full border border-gray-400 rounded-lg overflow-hidden"> class="flex flex-col h-full border border-gray-400 rounded-lg overflow-hidden">
<img src="{{ Storage::url($venue->image) }}" alt="{{ $venue->name }}" class="w-full h-48 object-cover"> <img src="{{ Storage::url($venue->image) }}" alt="{{ $venue->name }}" class="w-full h-48 object-cover">
<div class="flex-grow px-4 py-2"> <div class="flex-grow px-4 py-2">
<h3 class="text-sm text-gray-400 font-semibold mb-2">Venue</h3> <h3 class="text-sm text-gray-400 font-semibold mb-2">Venue</h3>
<h1 class="text-xl text-gray-800 font-semibold">{{ $venue->name }}</h1> <h1 class="text-xl text-gray-800 font-semibold">{{ $venue->name }}</h1>
{{-- <p class="text-sm text-gray-500">{{ $venue->address }}</p> --}} <p class="text-sm text-gray-600 mt-1">
<i class="fa-regular fa-clock"></i>
Buka: {{ date('H:i', strtotime($venue['open_time'])) }} -
{{ date('H:i', strtotime($venue['close_time'])) }}
</p>
<p class="mt-10 text-gray-500 text-sm">Mulai: <p class="mt-10 text-gray-500 text-sm">Mulai:
<span class="font-bold text-gray-800">Rp30,000</span> <span class="font-bold text-gray-800">Rp30,000</span>
<span class="text-gray-400 font-thin text-sm">/ jam</span> <span class="text-gray-400 font-thin text-sm">/ jam</span>

View File

@ -21,7 +21,6 @@ class="fixed inset-0 bg-black bg-opacity-50 z-40 flex items-center justify-cente
class="w-full h-full object-cover rounded-lg mb-4 mt-8" /> class="w-full h-full object-cover rounded-lg mb-4 mt-8" />
<h1 class="text-xl text-gray-800 font-semibold">{{ $venue['name'] }}</h1> <h1 class="text-xl text-gray-800 font-semibold">{{ $venue['name'] }}</h1>
<p class="text-sm text-gray-500">{{ $venue['location'] ?? 'Lokasi tidak tersedia' }}</p>
<p class="text-sm text-gray-600 mt-1"> <p class="text-sm text-gray-600 mt-1">
<i class="fa-regular fa-clock"></i> <i class="fa-regular fa-clock"></i>
Jam Operasional: {{ date('H:i', strtotime($venue['open_time'])) }} - Jam Operasional: {{ date('H:i', strtotime($venue['open_time'])) }} -
@ -183,12 +182,12 @@ function showToast(message, type = 'info', duration = 5000) {
toast.className = `${bgColor} text-white px-6 py-4 rounded-lg shadow-lg flex items-center space-x-3 min-w-80 transform transition-all duration-300 translate-x-full opacity-0`; toast.className = `${bgColor} text-white px-6 py-4 rounded-lg shadow-lg flex items-center space-x-3 min-w-80 transform transition-all duration-300 translate-x-full opacity-0`;
toast.innerHTML = ` toast.innerHTML = `
<i class="fas ${icon}"></i> <i class="fas ${icon}"></i>
<span class="flex-1">${message}</span> <span class="flex-1">${message}</span>
<button onclick="this.parentElement.remove()" class="text-white hover:text-gray-200"> <button onclick="this.parentElement.remove()" class="text-white hover:text-gray-200">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
`; `;
toastContainer.appendChild(toast); toastContainer.appendChild(toast);
@ -224,20 +223,20 @@ function showModal(title, message, type = 'info', callback = null) {
}[type] || 'fa-info-circle'; }[type] || 'fa-info-circle';
modal.innerHTML = ` modal.innerHTML = `
<div class="bg-white rounded-lg p-6 max-w-md w-full shadow-2xl transform transition-all"> <div class="bg-white rounded-lg p-6 max-w-md w-full shadow-2xl transform transition-all">
<div class="flex items-center space-x-3 mb-4"> <div class="flex items-center space-x-3 mb-4">
<i class="fas ${icon} text-2xl ${iconColor}"></i> <i class="fas ${icon} text-2xl ${iconColor}"></i>
<h3 class="text-lg font-semibold text-gray-800">${title}</h3> <h3 class="text-lg font-semibold text-gray-800">${title}</h3>
</div> </div>
<p class="text-gray-600 mb-6">${message}</p> <p class="text-gray-600 mb-6">${message}</p>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button onclick="this.closest('.fixed').remove(); ${callback ? callback + '()' : ''}" <button onclick="this.closest('.fixed').remove(); ${callback ? callback + '()' : ''}"
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-colors"> class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-colors">
OK OK
</button> </button>
</div> </div>
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
@ -256,22 +255,22 @@ function showConfirmModal(title, message, onConfirm, onCancel = null) {
modal.className = 'fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'; modal.className = 'fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4';
modal.innerHTML = ` modal.innerHTML = `
<div class="bg-white rounded-lg p-6 max-w-md w-full shadow-2xl transform transition-all"> <div class="bg-white rounded-lg p-6 max-w-md w-full shadow-2xl transform transition-all">
<div class="flex items-center space-x-3 mb-4"> <div class="flex items-center space-x-3 mb-4">
<i class="fas fa-question-circle text-2xl text-yellow-500"></i> <i class="fas fa-question-circle text-2xl text-yellow-500"></i>
<h3 class="text-lg font-semibold text-gray-800">${title}</h3> <h3 class="text-lg font-semibold text-gray-800">${title}</h3>
</div> </div>
<p class="text-gray-600 mb-6">${message}</p> <p class="text-gray-600 mb-6">${message}</p>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button id="cancelBtn" class="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 transition-colors"> <button id="cancelBtn" class="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 transition-colors">
Batal Batal
</button> </button>
<button id="confirmBtn" class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 transition-colors"> <button id="confirmBtn" class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 transition-colors">
Ya, Hapus Ya, Hapus
</button> </button>
</div> </div>
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
@ -630,29 +629,45 @@ function formatPrice(price) {
window.dispatchEvent(new CustomEvent('hide-loading')); window.dispatchEvent(new CustomEvent('hide-loading'));
if (data.success) { if (data.success) {
console.log("Opening payment with snap token:", data.snap_token); // Cek apakah ini admin direct booking atau customer payment
// Open Snap payment if (data.snap_token) {
window.snap.pay(data.snap_token, { // Customer biasa - perlu payment
onSuccess: (result) => { console.log("Opening payment with snap token:", data.snap_token);
this.createBooking(data.order_id, result); window.snap.pay(data.snap_token, {
}, onSuccess: (result) => {
onPending: (result) => { this.createBooking(data.order_id, result);
showToast('Pembayaran pending, silahkan selesaikan pembayaran', 'warning'); },
this.isLoading = false; onPending: (result) => {
}, showToast('Pembayaran pending, silahkan selesaikan pembayaran', 'warning');
onError: (result) => { this.isLoading = false;
showToast('Pembayaran gagal', 'error'); },
this.isLoading = false; onError: (result) => {
}, showToast('Pembayaran gagal', 'error');
onClose: () => { this.isLoading = false;
showToast('Anda menutup popup tanpa menyelesaikan pembayaran', 'warning'); },
this.isLoading = false; onClose: () => {
window.justClosedPayment = true; showToast('Anda menutup popup tanpa menyelesaikan pembayaran', 'warning');
this.isLoading = false;
window.justClosedPayment = true;
// Dispatch event to refresh pending bookings // Dispatch event to refresh pending bookings
document.dispatchEvent(refreshPendingBookingsEvent); document.dispatchEvent(refreshPendingBookingsEvent);
} }
}); });
} else if (data.booking_id) {
// Admin direct booking - langsung berhasil
showToast(data.message || 'Booking berhasil dibuat!', 'success');
this.isLoading = false;
// Refresh halaman atau reload available times
setTimeout(() => {
window.location.reload(); // Atau panggil method refresh yang sudah ada
}, 1000);
} else {
// Response success tapi tidak ada snap_token atau booking_id
showToast(data.message || 'Booking berhasil diproses', 'success');
this.isLoading = false;
}
} else { } else {
showToast(data.message, 'error'); showToast(data.message, 'error');
this.isLoading = false; this.isLoading = false;

View File

@ -26,7 +26,7 @@
Route::get('/venue/{venueName}', [VenueController::class, "venue"])->name('venue'); Route::get('/venue/{venueName}', [VenueController::class, "venue"])->name('venue');
// Changed routes for the new booking flow // Changed routes for the new booking flow
Route::post('/booking/payment-intent', [BookingController::class, 'createPaymentIntent'])->name('booking.payment-intent'); Route::post('/booking/initiate', [BookingController::class, 'createPaymentIntent'])->name('booking.initiate');
Route::post('/booking', [BookingController::class, 'store'])->name('booking.store'); Route::post('/booking', [BookingController::class, 'store'])->name('booking.store');
Route::get('/booking/schedules', [BookingController::class, 'getBookedSchedules'])->name('booking.schedules'); Route::get('/booking/schedules', [BookingController::class, 'getBookedSchedules'])->name('booking.schedules');
Route::post('/payment/notification', [BookingController::class, 'handleNotification'])->name('payment.notification'); Route::post('/payment/notification', [BookingController::class, 'handleNotification'])->name('payment.notification');