Switch remote

This commit is contained in:
Stephen Gesityan 2025-07-16 14:26:26 +07:00
parent 79307fb0d6
commit f0f2d5299d
26 changed files with 1655 additions and 711 deletions

View File

@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Models\Review;
use App\Models\Booking;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ReviewController extends Controller
{
/**
* Menyimpan ulasan baru ke dalam database.
*/
public function create(Booking $booking)
{
// Otorisasi: Pastikan user yang login adalah pemilik booking
if ($booking->user_id !== Auth::id()) {
abort(403, 'Akses tidak diizinkan.');
}
// Otorisasi: Pastikan booking sudah pernah diulas atau belum
if ($booking->review) {
return redirect()->route('booking.history')->with('error', 'Anda sudah pernah memberikan ulasan untuk booking ini.');
}
// Muat relasi yang diperlukan untuk ditampilkan di view
$booking->load('table.venue');
return view('reviews.create', compact('booking'));
}
public function store(Request $request)
{
// 1. Validasi Input
$request->validate([
'booking_id' => 'required|exists:bookings,id|unique:reviews,booking_id',
'rating' => 'required|integer|min:1|max:5',
'comment' => 'required|string|max:2000',
], [
'booking_id.unique' => 'Anda sudah pernah memberikan ulasan untuk booking ini.',
'rating.required' => 'Rating bintang wajib diisi.',
'comment.required' => 'Komentar ulasan wajib diisi.',
]);
// 2. Cek Otorisasi
$booking = Booking::find($request->booking_id);
// Pastikan user yang login adalah pemilik booking tersebut
if ($booking->user_id !== Auth::id()) {
return redirect()->back()->with('error', 'Akses tidak diizinkan.');
}
// 3. Simpan Ulasan
Review::create([
'user_id' => Auth::id(),
'venue_id' => $booking->table->venue_id, // Ambil venue_id dari relasi
'booking_id' => $booking->id,
'rating' => $request->rating,
'comment' => $request->comment,
]);
// 4. Redirect dengan Pesan Sukses
return redirect()->route('booking.history')->with('success', 'Terima kasih atas ulasan Anda!');
}
}

View File

@ -76,6 +76,42 @@ public function index(Request $request)
return view('admin.bookings.index', compact('bookings'));
}
public function showScanner()
{
return view('admin.bookings.scan');
}
public function validateBooking($token)
{
$booking = Booking::where('validation_token', $token)
->with(['user', 'table.venue'])
->first();
if (!$booking) {
return response()->json(['error' => 'Booking tidak ditemukan atau tidak valid.'], 404);
}
// Otorisasi: Pastikan admin hanya bisa memvalidasi booking di venue miliknya
if ($booking->table->venue_id !== auth()->user()->venue_id) {
return response()->json(['error' => 'Akses tidak diizinkan.'], 403);
}
// Mengembalikan data yang diformat dengan baik
return response()->json([
'success' => true,
'data' => [
'venue_name' => $booking->table->venue->name,
'booking_id' => $booking->id,
'user_name' => $booking->user->name,
'table_name' => $booking->table->name,
'start_time' => $booking->start_time->format('d M Y, H:i'),
'end_time' => $booking->end_time->format('H:i'),
'duration' => $booking->start_time->diffInHours($booking->end_time) . ' Jam',
'status' => $booking->status,
]
]);
}
public function show($id)
{
// Pastikan booking yang dilihat adalah milik venue admin

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\Venue;
use App\Models\VenueImage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
@ -36,7 +37,7 @@ public function index()
*/
public function edit()
{
$venue = auth()->user()->venue;
$venue = auth()->user()->venue()->with('images', 'operatingHours')->first(); // Tambahkan 'operatingHours'
if (!$venue) {
return redirect()->route('admin.dashboard')->with('error', 'Anda belum memiliki venue yang ditugaskan.');
@ -51,84 +52,115 @@ public function edit()
public function update(Request $request)
{
$venue = auth()->user()->venue;
if (!$venue) {
return redirect()->route('admin.dashboard')->with('error', 'Anda belum memiliki venue yang ditugaskan.');
}
// Validation rules
$validator = Validator::make($request->all(), [
// 1. Pra-proses data jam operasional sebelum validasi
$input = $request->all();
if (isset($input['hours'])) {
foreach ($input['hours'] as $day => &$data) { // Gunakan '&' untuk modifikasi langsung
// Jika 'is_closed' dicentang, atau jika input waktu kosong, paksa menjadi null
if (isset($data['is_closed']) && $data['is_closed'] == 'on') {
$data['open_time'] = null;
$data['close_time'] = null;
}
}
}
// 2. Buat aturan validasi yang lebih pintar
$validator = Validator::make($input, [
'name' => 'required|string|max:255',
'address' => 'required|string|max:500',
'phone' => 'nullable|string|max:20',
'description' => 'nullable|string|max:1000',
'open_time' => 'required|date_format:H:i',
'close_time' => 'required|date_format:H:i',
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', // Max 2MB
], [
'name.required' => 'Nama venue harus diisi.',
'address.required' => 'Alamat venue harus diisi.',
'open_time.required' => 'Jam buka harus diisi.',
'open_time.date_format' => 'Format jam buka tidak valid (gunakan format HH:MM).',
'close_time.required' => 'Jam tutup harus diisi.',
'close_time.date_format' => 'Format jam tutup tidak valid (gunakan format HH:MM).',
'image.image' => 'File yang diupload harus berupa gambar.',
'image.mimes' => 'Gambar harus berformat: jpeg, png, jpg, atau gif.',
'image.max' => 'Ukuran gambar maksimal 2MB.',
'latitude' => 'nullable|string|max:255',
'longitude' => 'nullable|string|max:255',
// ... (validasi lain tetap sama) ...
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
'gallery_images.*' => 'image|mimes:jpeg,png,jpg,gif|max:2048',
'hours' => 'required|array|size:7',
// Aturan baru: open_time wajib diisi KECUALI jika is_closed dicentang
'hours.*.open_time' => 'required_unless:hours.*.is_closed,on|nullable|date_format:H:i',
'hours.*.close_time' => 'required_unless:hours.*.is_closed,on|nullable|date_format:H:i',
'link_instagram' => 'nullable|url',
'link_tiktok' => 'nullable|url',
'link_facebook' => 'nullable|url',
'link_x' => 'nullable|url',
]);
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator)
->withInput();
return redirect()->back()->withErrors($validator)->withInput();
}
try {
// Handle image upload
$imagePath = $venue->image; // Keep current image by default
// (Sisa dari kode ini tidak perlu diubah)
$imagePath = $venue->image;
if ($request->hasFile('image')) { /* ... logika upload ... */ }
if ($request->hasFile('gallery_images')) { /* ... logika upload galeri ... */ }
if ($request->hasFile('image')) {
// Delete old image if exists
if ($venue->image && Storage::disk('public')->exists($venue->image)) {
Storage::disk('public')->delete($venue->image);
}
// Store new image
$imagePath = $request->file('image')->store('venues', 'public');
}
// Prepare update data
$updateData = [
$venue->update([
'name' => $request->name,
'address' => $request->address,
'latitude' => $request->latitude,
'longitude' => $request->longitude,
'phone' => $request->phone,
'link_instagram' => $request->link_instagram,
'link_tiktok' => $request->link_tiktok,
'link_facebook' => $request->link_facebook,
'link_x' => $request->link_x,
'description' => $request->description,
'image' => $imagePath,
];
]);
// Only update operating hours if venue is open
if ($venue->status === 'open') {
$updateData['open_time'] = $request->open_time;
$updateData['close_time'] = $request->close_time;
} else {
// If venue is closed, update original times
$updateData['original_open_time'] = $request->open_time;
$updateData['original_close_time'] = $request->close_time;
foreach ($request->hours as $dayNumber => $hoursData) {
$isClosed = isset($hoursData['is_closed']);
$venue->operatingHours()->updateOrCreate(
['day_of_week' => $dayNumber],
[
'open_time' => $isClosed ? null : $hoursData['open_time'],
'close_time' => $isClosed ? null : $hoursData['close_time'],
'is_closed' => $isClosed,
]
);
}
// Update venue data
$venue->update($updateData);
return redirect()->route('admin.venue.index')
->with('success', 'Informasi venue berhasil diperbarui!');
} catch (\Exception $e) {
return redirect()->back()
->with('error', 'Terjadi kesalahan saat memperbarui venue: ' . $e->getMessage())
->with('error', 'Terjadi kesalahan: ' . $e->getMessage())
->withInput();
}
}
public function destroyImage(VenueImage $image)
{
// Otorisasi: Pastikan admin yang sedang login adalah pemilik venue dari gambar ini
// Kita bandingkan venue_id milik admin dengan venue_id milik gambar.
if ($image->venue_id !== auth()->user()->venue_id) {
return response()->json(['success' => false, 'message' => 'Akses tidak diizinkan.'], 403);
}
try {
// Hapus file dari storage
if (Storage::disk('public')->exists($image->path)) {
Storage::disk('public')->delete($image->path);
}
// Hapus record dari database
$image->delete();
return response()->json(['success' => true, 'message' => 'Gambar berhasil dihapus.']);
} catch (\Exception $e) {
// Catat error untuk debugging jika perlu
// Log::error('Failed to delete gallery image: ' . $e->getMessage());
// Kirim respons error yang jelas
return response()->json(['success' => false, 'message' => 'Gagal menghapus gambar di server.'], 500);
}
}
/**
* Toggle venue status (open/close)
*/

View File

@ -12,6 +12,8 @@
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str; // <-- 1. PASTIKAN INI ADA
use SimpleSoftwareIO\QrCode\Facades\QrCode; // <-- 1. PASTIKAN INI JUGA ADA
class BookingController extends Controller
{
@ -22,6 +24,25 @@ public function __construct(MidtransService $midtransService)
$this->midtransService = $midtransService;
}
public function showQrCode(Booking $booking)
{
// Otorisasi: Pastikan user yang login adalah pemilik booking
if ($booking->user_id !== Auth::id()) {
abort(403);
}
// Pastikan booking memiliki token validasi
if (!$booking->validation_token) {
abort(404, 'QR Code tidak ditemukan.');
}
// Buat gambar QR Code dari token validasi
$qrCode = QrCode::size(300)->generate($booking->validation_token);
// Kembalikan sebagai respons gambar SVG
return response($qrCode)->header('Content-Type', 'image/svg+xml');
}
// Tambahkan method baru untuk booking langsung oleh admin
// Ganti seluruh fungsi adminDirectBooking dengan ini
public function adminDirectBooking($request) {
@ -33,10 +54,9 @@ public function adminDirectBooking($request) {
}
$user = Auth::user();
$table = Table::with('venue')->findOrFail($data['table_id']);
$table = Table::with(['venue.operatingHours'])->findOrFail($data['table_id']);
$venue = $table->venue;
// Validasi otorisasi admin (menggunakan struktur yang konsisten dengan kodemu)
if ($user->role !== 'admin' || $user->venue_id !== $table->venue_id) {
return response()->json(['message' => 'Unauthorized action'], 403);
}
@ -44,49 +64,46 @@ public function adminDirectBooking($request) {
$startDateTime = Carbon::parse($data['start_time']);
$endDateTime = Carbon::parse($data['end_time']);
// --- Validasi jam operasional (logika ini sudah benar) ---
$operationalDayStart = Carbon::createFromFormat('Y-m-d H:i:s', $startDateTime->format('Y-m-d') . ' ' . $venue->open_time, 'Asia/Jakarta');
if ($venue->is_overnight && $startDateTime < $operationalDayStart) {
$operationalDayStart->subDay();
// --- LOGIKA BARU: Validasi Jam Operasional Harian ---
$bookingDate = $startDateTime->copy()->setTimezone('Asia/Jakarta');
$dayOfWeek = $bookingDate->dayOfWeekIso;
$todaysHours = $venue->operatingHours->firstWhere('day_of_week', $dayOfWeek);
if (!$todaysHours || $todaysHours->is_closed) {
return response()->json(['message' => 'Venue tutup pada tanggal yang dipilih.'], 400);
}
$operationalDayEnd = $operationalDayStart->copy()->setTimeFromTimeString($venue->close_time);
if ($venue->is_overnight) {
$openTimeToday = $todaysHours->open_time;
$closeTimeToday = $todaysHours->close_time;
$isOvernightToday = strtotime($closeTimeToday) < strtotime($openTimeToday);
$operationalDayStart = Carbon::createFromFormat('Y-m-d H:i:s', $startDateTime->format('Y-m-d') . ' ' . $openTimeToday, 'Asia/Jakarta');
if ($isOvernightToday && $startDateTime < $operationalDayStart) {
$operationalDayStart->subDay();
}
$operationalDayEnd = $operationalDayStart->copy()->setTimeFromTimeString($closeTimeToday);
if ($isOvernightToday) {
$operationalDayEnd->addDay();
}
if ($startDateTime->lt($operationalDayStart) || $endDateTime->gt($operationalDayEnd)) {
Log::warning('Admin direct booking attempt outside operational hours.', [
'start_time' => $startDateTime->toDateTimeString(),
'venue_open' => $operationalDayStart->toDateTimeString(),
'venue_close' => $operationalDayEnd->toDateTimeString(),
]);
return response()->json(['message' => 'Waktu booking di luar jam operasional venue.'], 400);
}
// --- Akhir Validasi jam operasional ---
// --- AKHIR VALIDASI BARU ---
// --- PERBAIKAN LOGIKA KONFLIK DIMULAI DI SINI ---
// Kita hapus ->whereDate() dan langsung cek bentrokan waktu.
$conflict = Booking::where('table_id', $data['table_id'])
->whereIn('status', ['paid', 'pending'])
->where(function($query) use ($startDateTime, $endDateTime) {
// Booking yang baru tidak boleh dimulai di tengah booking lain.
// Booking yang baru juga tidak boleh berakhir di tengah booking lain.
// Booking yang baru juga tidak boleh "menelan" booking lain.
$query->where('start_time', '<', $endDateTime)
->where('end_time', '>', $startDateTime);
})
->where('start_time', '<', $endDateTime)
->where('end_time', '>', $startDateTime)
->exists();
if ($conflict) {
return response()->json(['message' => 'Meja sudah dibooking di jam tersebut'], 409);
}
// --- AKHIR DARI PERBAIKAN LOGIKA KONFLIK ---
// Hitung total biaya dan durasi
// Sisa fungsi tidak berubah
$duration = $endDateTime->diffInHours($startDateTime);
$totalAmount = $duration * $table->price_per_hour;
$adminOrderId = 'ADMIN-' . $user->id . '-' . time();
$booking = Booking::create([
@ -96,86 +113,80 @@ public function adminDirectBooking($request) {
'end_time' => $endDateTime,
'status' => 'paid',
'total_amount' => $totalAmount,
'payment_id' => null,
'payment_method' => 'admin_direct',
'order_id' => $adminOrderId,
'validation_token' => (string) Str::uuid(),
]);
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, ',', '.')
]
// ... (detail booking lainnya)
]);
} 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()
]);
\Log::error('Admin direct booking error:', ['message' => $e->getMessage()]);
return response()->json(['success' => false, 'message' => 'Gagal membuat booking: ' . $e->getMessage()], 500);
}
}
public function createPaymentIntent(Request $request) {
try {
$request->validate([
'table_id' => 'required|exists:tables,id',
'start_time' => 'required',
'duration' => 'required|integer|min:1|max:12',
'booking_date' => 'required|date_format:Y-m-d',
]);
$user = Auth::user();
$table = Table::with('venue')->findOrFail($request->table_id);
$venue = $table->venue;
$bookingDate = $request->booking_date;
$startTimeString = $request->start_time;
$duration = (int) $request->duration;
// 1. Hitung start & end time yang sebenarnya
$startDateTime = Carbon::createFromFormat('Y-m-d H:i', $bookingDate . ' ' . $startTimeString, 'Asia/Jakarta');
// --- AWAL PERBAIKAN LOGIKA STRING COMPARISON ---
$startTimeObject = Carbon::createFromFormat('H:i', $startTimeString);
$openTimeObject = Carbon::parse($venue->open_time);
// Bandingkan sebagai objek Carbon, bukan string
if ($venue->is_overnight && $startTimeObject->lt($openTimeObject)) {
$startDateTime->addDay();
}
$endDateTime = $startDateTime->copy()->addHours($duration);
// 2. --- BLOK VALIDASI YANG DIPERBAIKI ---
$operationalDayStart = Carbon::createFromFormat('Y-m-d H:i:s', $startDateTime->format('Y-m-d') . ' ' . $venue->open_time, 'Asia/Jakarta');
if ($venue->is_overnight && $startDateTime < $operationalDayStart) {
$operationalDayStart->subDay();
}
$operationalDayEnd = $operationalDayStart->copy()->setTimeFromTimeString($venue->close_time);
if ($venue->is_overnight) {
$operationalDayEnd->addDay();
}
if ($startDateTime->lt($operationalDayStart) || $endDateTime->gt($operationalDayEnd)) {
Log::warning('Booking attempt outside operational hours.', [
'start_time' => $startDateTime->toDateTimeString(), 'end_time' => $endDateTime->toDateTimeString(),
'venue_open' => $operationalDayStart->toDateTimeString(), 'venue_close' => $operationalDayEnd->toDateTimeString(),
try {
$request->validate([
'table_id' => 'required|exists:tables,id',
'start_time' => 'required',
'duration' => 'required|integer|min:1|max:12',
'booking_date' => 'required|date_format:Y-m-d',
]);
return response()->json(['success' => false, 'message' => 'Durasi booking di luar jam operasional venue.'], 422);
}
// --- AKHIR DARI BLOK VALIDASI ---
// 3. Cek untuk admin direct booking (tidak berubah)
if ($user->role === 'admin' && $user->venue_id === $table->venue_id) {
$user = Auth::user();
$table = Table::with(['venue.operatingHours'])->findOrFail($request->table_id); // Muat relasi operatingHours
$venue = $table->venue;
$bookingDate = Carbon::createFromFormat('Y-m-d', $request->booking_date, 'Asia/Jakarta');
$startTimeString = $request->start_time;
$duration = (int) $request->duration;
// --- START LOGIKA BARU: Ambil Jadwal Hari Ini ---
$dayOfWeek = $bookingDate->dayOfWeekIso; // 1=Senin, 7=Minggu
$todaysHours = $venue->operatingHours->firstWhere('day_of_week', $dayOfWeek);
if (!$todaysHours || $todaysHours->is_closed) {
return response()->json(['success' => false, 'message' => 'Venue tutup pada tanggal yang dipilih.'], 422);
}
// Gunakan jam dari jadwal harian, bukan dari $venue->open_time
$openTimeToday = $todaysHours->open_time;
$closeTimeToday = $todaysHours->close_time;
$isOvernightToday = strtotime($closeTimeToday) < strtotime($openTimeToday);
// --- END LOGIKA BARU ---
$startDateTime = Carbon::createFromFormat('Y-m-d H:i', $bookingDate->format('Y-m-d') . ' ' . $startTimeString, 'Asia/Jakarta');
if ($isOvernightToday && (strtotime($startTimeString) < strtotime($openTimeToday))) {
$startDateTime->addDay();
}
$endDateTime = $startDateTime->copy()->addHours($duration);
// Validasi jam operasional menggunakan data dinamis
$operationalDayStart = Carbon::createFromFormat('Y-m-d H:i:s', $startDateTime->format('Y-m-d') . ' ' . $openTimeToday, 'Asia/Jakarta');
if ($isOvernightToday && $startDateTime < $operationalDayStart) {
$operationalDayStart->subDay();
}
$operationalDayEnd = $operationalDayStart->copy()->setTimeFromTimeString($closeTimeToday);
if ($isOvernightToday) {
$operationalDayEnd->addDay();
}
if ($startDateTime->lt($operationalDayStart) || $endDateTime->gt($operationalDayEnd)) {
return response()->json(['success' => false, 'message' => 'Durasi booking di luar jam operasional venue.'], 422);
}
// Sisa dari fungsi ini (cek admin, cek konflik, proses Midtrans) tidak perlu diubah.
// ... (kode lama Anda untuk cek admin, cek konflik, dll, tetap di sini) ...
if ($user->role === 'admin' && $user->venue_id === $table->venue_id) {
return $this->adminDirectBooking(collect([
'table_id' => $request->table_id,
'start_time' => $startDateTime->toDateTimeString(),
@ -183,7 +194,7 @@ public function createPaymentIntent(Request $request) {
]));
}
// 4. Cek konflik booking (tidak berubah)
// Cek konflik booking (tidak berubah)
$conflict = Booking::where('table_id', $request->table_id)
->where('status', 'paid')
->where(function($query) use ($startDateTime, $endDateTime) {
@ -195,7 +206,7 @@ public function createPaymentIntent(Request $request) {
return response()->json(['success' => false, 'message' => 'Meja sudah dibooking di jam tersebut'], 409);
}
// 5. Proses ke Midtrans (tidak berubah)
// Proses ke Midtrans (tidak berubah)
$totalAmount = $duration * $table->price_per_hour;
$tempOrderId = 'TEMP-' . Auth::id() . '-' . time();
@ -214,18 +225,19 @@ public function createPaymentIntent(Request $request) {
'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);
} 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) {
try {
@ -273,6 +285,7 @@ public function store(Request $request) {
'payment_id' => $request->transaction_id,
'payment_method' => $request->payment_method,
'order_id' => $request->order_id,
'validation_token' => (string) Str::uuid(),
]);
// Update table status to booked

View File

@ -1,34 +1,42 @@
<?php
// app/Http/Controllers/pages/VenueController.php
namespace App\Http\Controllers\pages;
use App\Http\Controllers\Controller;
use App\Models\Venue; // Pastikan model Venue di-import
use Illuminate\Http\Request;
use App\Models\Venue;
use Carbon\Carbon; // <-- Pastikan Carbon di-import
class VenueController extends Controller
{
public function venue($venueName) {
// Mengambil venue berdasarkan nama yang diberikan
$venue = Venue::where('name', 'like', '%' . ucfirst($venueName) . '%')->first();
public function venue($venueName)
{
$venue = Venue::where('name', $venueName)
->with(['tables', 'images', 'operatingHours', 'reviews.user'])
->firstOrFail();
// Jika venue tidak ditemukan, tampilkan error 404
if (!$venue) {
abort(404);
// --- LOGIKA BARU UNTUK JAM OPERASIONAL HARI INI ---
// Day of week: 1 (Senin) - 7 (Minggu). Carbon menggunakan 0 (Minggu) - 6 (Sabtu)
$dayOfWeek = Carbon::now('Asia/Jakarta')->dayOfWeekIso; // ISO standard: 1=Senin, 7=Minggu
// Cari jadwal untuk hari ini dari data yang sudah dimuat
$todaysHours = $venue->operatingHours->firstWhere('day_of_week', $dayOfWeek);
// Siapkan data jam buka dan tutup untuk hari ini
// Jika tidak ada jadwal spesifik, atau jika hari ini libur, maka venue dianggap tutup
if ($todaysHours && !$todaysHours->is_closed) {
$openTime = $todaysHours->open_time;
$closeTime = $todaysHours->close_time;
} else {
// Set default ke 'tutup' jika tidak ada jadwal atau is_closed = true
$openTime = '00:00';
$closeTime = '00:00';
}
// --- AKHIR LOGIKA BARU ---
$averageRating = $venue->reviews->avg('rating');
$totalReviews = $venue->reviews->count();
// Ambil tabel-tabel terkait dengan venue
$venue->load('tables'); // Eager loading untuk optimasi
// Parsing jam operasional dari format H:i:s menjadi integer
$openHour = (int) date('H', strtotime($venue->open_time));
$closeHour = (int) date('H', strtotime($venue->close_time));
// Mengirim data venue dengan jam operasional ke view
return view('pages.venue', [
'venue' => $venue,
'openHour' => $openHour,
'closeHour' => $closeHour
]);
// Kirim semua data ke view
return view('pages.venue', compact('venue', 'openTime', 'closeTime', 'averageRating', 'totalReviews'));
}
}

View File

@ -18,7 +18,9 @@ class Booking extends Model
'payment_id',
'payment_method',
'total_amount',
'payment_expired_at'
'payment_expired_at',
'order_id', // Pastikan order_id juga ada di sini jika belum
'validation_token', // <-- TAMBAHKAN INI
];
protected $casts = [
@ -38,6 +40,11 @@ public function user()
return $this->belongsTo(User::class);
}
public function review()
{
return $this->hasOne(Review::class);
}
public function isPending()
{
return $this->status === 'pending';

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OperatingHour extends Model
{
use HasFactory;
protected $fillable = [
'venue_id',
'day_of_week',
'open_time',
'close_time',
'is_closed',
];
public function venue()
{
return $this->belongsTo(Venue::class);
}
}

34
app/Models/Review.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Review extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'venue_id',
'booking_id',
'rating',
'comment',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function venue()
{
return $this->belongsTo(Venue::class);
}
public function booking()
{
return $this->belongsTo(Booking::class);
}
}

View File

@ -66,6 +66,11 @@ public function hasRole($role)
return $this->role === $role;
}
public function reviews()
{
return $this->hasMany(Review::class);
}
/**
* Get the venue that the admin belongs to.
*/

View File

@ -5,6 +5,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;
use App\Models\VenueImage; // <-- Tambahkan ini
class Venue extends Model
{
@ -19,8 +20,14 @@ public function getIsOvernightAttribute()
protected $fillable = [
'name',
'address',
'latitude',
'longitude',
'image',
'phone',
'link_instagram',
'link_tiktok',
'link_facebook',
'link_x',
'description',
'open_time',
'close_time',
@ -49,6 +56,21 @@ public function tables()
return $this->hasMany(Table::class);
}
public function images()
{
return $this->hasMany(VenueImage::class);
}
public function operatingHours()
{
return $this->hasMany(OperatingHour::class);
}
public function reviews()
{
return $this->hasMany(Review::class);
}
/**
* Check if venue should automatically reopen
*/

18
app/Models/VenueImage.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class VenueImage extends Model
{
use HasFactory;
protected $fillable = ['venue_id', 'path'];
public function venue()
{
return $this->belongsTo(Venue::class);
}
}

View File

@ -13,7 +13,8 @@
"laravel/tinker": "^2.8",
"laravel/ui": "^4.6",
"maatwebsite/excel": "^1.1",
"midtrans/midtrans-php": "^2.6"
"midtrans/midtrans-php": "^2.6",
"simplesoftwareio/simple-qrcode": "^4.2"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",

174
composer.lock generated
View File

@ -4,8 +4,62 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bce8ddce84cf8a2d572b421f6992903e",
"content-hash": "66130e103a2b9c249e50df9d5700a24f",
"packages": [
{
"name": "bacon/bacon-qr-code",
"version": "2.0.8",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phly/keep-a-changelog": "^2.1",
"phpunit/phpunit": "^7 | ^8 | ^9",
"spatie/phpunit-snapshot-assertions": "^4.2.9",
"squizlabs/php_codesniffer": "^3.4"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
},
"time": "2022-12-07T17:46:57+00:00"
},
{
"name": "brick/math",
"version": "0.12.1",
@ -135,6 +189,56 @@
],
"time": "2023-12-11T17:09:12+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.6",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8dfd07c6d2cf31c8da90c53b83c026c7696dda90",
"reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.6"
},
"time": "2024-08-09T14:30:48+00:00"
},
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@ -3812,6 +3916,74 @@
],
"time": "2024-04-27T21:32:50+00:00"
},
{
"name": "simplesoftwareio/simple-qrcode",
"version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537",
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^2.0",
"ext-gd": "*",
"php": ">=7.2|^8.0"
},
"require-dev": {
"mockery/mockery": "~1",
"phpunit/phpunit": "~9"
},
"suggest": {
"ext-imagick": "Allows the generation of PNG QrCodes.",
"illuminate/support": "Allows for use within Laravel."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode"
},
"providers": [
"SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SimpleSoftwareIO\\QrCode\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Simple Software LLC",
"email": "support@simplesoftware.io"
}
],
"description": "Simple QrCode is a QR code generator made for Laravel.",
"homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode",
"keywords": [
"Simple",
"generator",
"laravel",
"qrcode",
"wrapper"
],
"support": {
"issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues",
"source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0"
},
"time": "2021-02-08T20:43:55+00:00"
},
{
"name": "symfony/console",
"version": "v6.4.12",

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateVenueImagesTable extends Migration
{
public function up()
{
Schema::create('venue_images', function (Blueprint $table) {
$table->id();
$table->foreignId('venue_id')->constrained()->onDelete('cascade');
$table->string('path');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('venue_images');
}
}

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateOperatingHoursTable extends Migration
{
public function up()
{
Schema::create('operating_hours', function (Blueprint $table) {
$table->id();
$table->foreignId('venue_id')->constrained()->onDelete('cascade');
$table->tinyInteger('day_of_week'); // 1 untuk Senin, 2 Selasa, ..., 7 Minggu
$table->time('open_time')->nullable();
$table->time('close_time')->nullable();
$table->boolean('is_closed')->default(false);
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('operating_hours');
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddSocialMediaLinksToVenuesTable extends Migration
{
public function up()
{
Schema::table('venues', function (Blueprint $table) {
$table->string('link_instagram')->nullable()->after('phone');
$table->string('link_tiktok')->nullable()->after('link_instagram');
$table->string('link_facebook')->nullable()->after('link_tiktok');
$table->string('link_x')->nullable()->after('link_facebook'); // Untuk Twitter/X
});
}
public function down()
{
Schema::table('venues', function (Blueprint $table) {
$table->dropColumn(['link_instagram', 'link_tiktok', 'link_facebook', 'link_x']);
});
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('venues', function (Blueprint $table) {
$table->string('latitude')->nullable()->after('address');
$table->string('longitude')->nullable()->after('latitude');
});
}
public function down()
{
Schema::table('venues', function (Blueprint $table) {
$table->dropColumn(['latitude', 'longitude']);
});
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('reviews', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('venue_id')->constrained()->onDelete('cascade');
$table->foreignId('booking_id')->constrained()->onDelete('cascade')->unique();
$table->tinyInteger('rating'); // 1-5 stars
$table->text('comment')->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('reviews');
}
};

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('bookings', function (Blueprint $table) {
// Tambahkan kolom 'order_id' yang hilang terlebih dahulu
$table->string('order_id')->nullable()->unique()->after('payment_method');
// Kemudian, tambahkan kolom 'validation_token' setelah 'order_id'
$table->string('validation_token')->nullable()->unique()->after('order_id');
});
}
public function down()
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn('validation_token');
$table->dropColumn('order_id');
});
}
};

View File

@ -0,0 +1,84 @@
@extends('layouts.admin')
@section('content')
<div class="p-6">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Scan QR Code Validasi</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-6 rounded-lg shadow-sm border">
<div id="qr-reader" class="w-full"></div>
<p id="qr-reader-status" class="text-center text-sm text-gray-500 mt-2">Arahkan kamera ke QR Code...</p>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm border">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Hasil Scan</h3>
<div id="qr-scan-result">
<div class="text-center py-10">
<p class="text-gray-400">Menunggu hasil scan...</p>
</div>
</div>
</div>
</div>
</div>
{{-- Script untuk library QR Scanner --}}
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const resultContainer = document.getElementById('qr-scan-result');
const statusContainer = document.getElementById('qr-reader-status');
function onScanSuccess(decodedText, decodedResult) {
// Hentikan scanner setelah berhasil
html5QrcodeScanner.clear();
statusContainer.innerHTML = `<span class="font-bold text-green-600">Scan Berhasil!</span> Memuat data...`;
// Kirim token ke server untuk divalidasi
fetch(`/admin/bookings/validate/${decodedText}`)
.then(response => {
if (!response.ok) {
// Jika booking tidak ditemukan (error 404) atau error lainnya
throw new Error(`Booking tidak valid atau terjadi error (Status: ${response.status})`);
}
return response.json();
})
.then(data => {
if (data.success) {
const booking = data.data;
// Tampilkan hasil yang valid
resultContainer.innerHTML = `
<div class="p-4 bg-green-50 border-l-4 border-green-500">
<h4 class="text-xl font-bold text-green-800"> Valid!</h4>
<div class="mt-4 space-y-2 text-sm">
<p><strong class="w-24 inline-block">Nama User:</strong> ${booking.user_name}</p>
<p><strong class="w-24 inline-block">Venue:</strong> ${booking.venue_name}</p>
<p><strong class="w-24 inline-block">Meja:</strong> ${booking.table_name}</p>
<p><strong class="w-24 inline-block">Waktu:</strong> ${booking.start_time} - ${booking.end_time}</p>
<p><strong class="w-24 inline-block">Durasi:</strong> ${booking.duration}</p>
<p><strong class="w-24 inline-block">Status:</strong> <span class="font-bold capitalize">${booking.status}</span></p>
</div>
</div>
`;
} else {
// Jika ada pesan error dari server
resultContainer.innerHTML = `<div class="p-4 bg-red-50 border-l-4 border-red-500"><h4 class="text-xl font-bold text-red-800">❌ Tidak Valid!</h4><p class="mt-2 text-red-700">${data.error || 'Data booking tidak bisa diambil.'}</p></div>`;
}
})
.catch(error => {
console.error("Error:", error);
// Tampilkan error jika fetch gagal
resultContainer.innerHTML = `<div class="p-4 bg-red-50 border-l-4 border-red-500"><h4 class="text-xl font-bold text-red-800">❌ Error!</h4><p class="mt-2 text-red-700">${error.message}</p></div>`;
});
}
// Inisialisasi scanner
var html5QrcodeScanner = new Html5QrcodeScanner(
"qr-reader", { fps: 10, qrbox: 250 }
);
// Render scanner
html5QrcodeScanner.render(onScanSuccess);
});
</script>
@endsection

View File

@ -2,14 +2,10 @@
@section('content')
<div class="p-6">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center space-x-4">
<a href="{{ route('admin.venue.index') }}" class="text-gray-500 hover:text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</a>
<div>
<h1 class="text-2xl font-bold text-gray-900">Edit Venue</h1>
@ -18,189 +14,192 @@
</div>
</div>
<!-- Alert Messages -->
@if(session('success'))
<div class="mb-6 bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded-lg">
{{ session('success') }}
</div>
<div class="mb-6 bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded-lg">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg">{{ session('error') }}</div>
@endif
@if ($errors->any())
<div class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg">
{{ session('error') }}
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<!-- Edit Form -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
<form action="{{ route('admin.venue.update') }}" method="POST" enctype="multipart/form-data" class="p-6">
@csrf
@method('PUT')
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-6">
<div class="space-y-6">
<!-- Venue Name -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
Nama Venue <span class="text-red-500">*</span>
</label>
<input type="text" id="name" name="name" value="{{ old('name', $venue->name) }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('name') border-red-500 @enderror"
placeholder="Masukkan nama venue">
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">Nama Venue <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" value="{{ old('name', $venue->name) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" required>
</div>
<!-- Address -->
<div>
<label for="address" class="block text-sm font-medium text-gray-700 mb-2">
Alamat <span class="text-red-500">*</span>
</label>
<textarea id="address" name="address" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('address') border-red-500 @enderror"
placeholder="Masukkan alamat lengkap venue">{{ old('address', $venue->address) }}</textarea>
@error('address')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
<label for="address" class="block text-sm font-medium text-gray-700 mb-2">Alamat <span class="text-red-500">*</span></label>
<textarea id="address" name="address" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" required>{{ old('address', $venue->address) }}</textarea>
</div>
<!-- Phone -->
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 mb-2">
Nomor Telepon
</label>
<input type="text" id="phone" name="phone" value="{{ old('phone', $venue->phone) }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('phone') border-red-500 @enderror"
placeholder="Contoh: 08123456789">
@error('phone')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Operating Hours -->
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="open_time" class="block text-sm font-medium text-gray-700 mb-2">
Jam Buka <span class="text-red-500">*</span>
</label>
<input type="time" id="open_time" name="open_time"
value="{{ old('open_time', $venue->open_time ? \Carbon\Carbon::parse($venue->open_time)->format('H:i') : '') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('open_time') border-red-500 @enderror">
@error('open_time')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
<label for="latitude" class="block text-sm font-medium text-gray-700 mb-2">Latitude</label>
<input type="text" id="latitude" name="latitude" value="{{ old('latitude', $venue->latitude) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="-8.123456">
</div>
<div>
<label for="close_time" class="block text-sm font-medium text-gray-700 mb-2">
Jam Tutup <span class="text-red-500">*</span>
</label>
<input type="time" id="close_time" name="close_time"
value="{{ old('close_time', $venue->close_time ? \Carbon\Carbon::parse($venue->close_time)->format('H:i') : '') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('close_time') border-red-500 @enderror">
@error('close_time')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
<label for="longitude" class="block text-sm font-medium text-gray-700 mb-2">Longitude</label>
<input type="text" id="longitude" name="longitude" value="{{ old('longitude', $venue->longitude) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="113.123456">
</div>
</div>
</div>
<!-- Right Column -->
<div class="space-y-6">
<!-- Current Image -->
<p class="text-xs text-gray-500 -mt-4">
*Cara mendapatkan: Buka Google Maps, klik kanan pada lokasi venue, lalu klik pada angka koordinat untuk menyalinnya.
</p>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Foto Venue Saat Ini
</label>
<div class="w-full h-48 bg-gray-100 rounded-lg overflow-hidden">
@if($venue->image)
<img id="current-image" src="{{ asset('storage/' . $venue->image) }}"
alt="{{ $venue->name }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full flex items-center justify-center text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
@endif
</div>
<label for="phone" class="block text-sm font-medium text-gray-700 mb-2">Nomor Telepon</label>
<input type="text" id="phone" name="phone" value="{{ old('phone', $venue->phone) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<!-- New Image Upload -->
<div>
<label for="image" class="block text-sm font-medium text-gray-700 mb-2">
Upload Foto Baru (Opsional)
</label>
<input type="file" id="image" name="image" accept="image/*" onchange="previewImage(this)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('image') border-red-500 @enderror">
@error('image')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
<p class="text-xs text-gray-500 mt-1">Format: JPEG, PNG, JPG, GIF. Maksimal 2MB</p>
<!-- Image Preview -->
<div id="image-preview" class="mt-4 hidden">
<p class="text-sm font-medium text-gray-700 mb-2">Preview Foto Baru:</p>
<div class="w-full h-48 bg-gray-100 rounded-lg overflow-hidden">
<img id="preview-img" src="" alt="Preview" class="w-full h-full object-cover">
<div class="pt-4 border-t">
<h3 class="text-base font-medium text-gray-800 mb-3">Link Media Sosial</h3>
<div class="space-y-4">
<div>
<label for="link_instagram" class="block text-sm font-medium text-gray-700 mb-2">Instagram</label>
<input type="url" id="link_instagram" name="link_instagram" value="{{ old('link_instagram', $venue->link_instagram) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="https://instagram.com/venueanda">
</div>
<div>
<label for="link_tiktok" class="block text-sm font-medium text-gray-700 mb-2">TikTok</label>
<input type="url" id="link_tiktok" name="link_tiktok" value="{{ old('link_tiktok', $venue->link_tiktok) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="https://tiktok.com/@venueanda">
</div>
<div>
<label for="link_facebook" class="block text-sm font-medium text-gray-700 mb-2">Facebook</label>
<input type="url" id="link_facebook" name="link_facebook" value="{{ old('link_facebook', $venue->link_facebook) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="https://facebook.com/venueanda">
</div>
<div>
<label for="link_x" class="block text-sm font-medium text-gray-700 mb-2">X (Twitter)</label>
<input type="url" id="link_x" name="link_x" value="{{ old('link_x', $venue->link_x) }}" class="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="https://x.com/venueanda">
</div>
</div>
</div>
<div class="p-4 border border-gray-200 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">Jam Operasional Harian</h3>
<div class="space-y-4">
@php
$days = [1 => 'Senin', 2 => 'Selasa', 3 => 'Rabu', 4 => 'Kamis', 5 => 'Jumat', 6 => 'Sabtu', 7 => 'Minggu'];
// Siapkan data jam operasional yang ada agar mudah diakses
$hoursByDay = $venue->operatingHours->keyBy('day_of_week');
@endphp
@foreach($days as $dayNumber => $dayName)
@php
$hour = $hoursByDay->get($dayNumber);
@endphp
<div x-data="{ isClosed: {{ old('hours.'.$dayNumber.'.is_closed', $hour->is_closed ?? true) ? 'true' : 'false' }} }" class="grid grid-cols-1 sm:grid-cols-4 gap-3 items-center">
<div class="sm:col-span-1">
<label class="font-medium text-gray-700">{{ $dayName }}</label>
</div>
<div class="sm:col-span-3 flex items-center space-x-4">
<input type="checkbox" x-model="isClosed" name="hours[{{ $dayNumber }}][is_closed]" class="h-4 w-4 rounded">
<label class="text-sm text-gray-600">Tutup</label>
<div class="flex items-center space-x-2" x-show="!isClosed">
<input type="time" name="hours[{{ $dayNumber }}][open_time]" value="{{ old('hours.'.$dayNumber.'.open_time', $hour && $hour->open_time ? \Carbon\Carbon::parse($hour->open_time)->format('H:i') : '09:00') }}" :disabled="isClosed" class="w-full px-2 py-1 border border-gray-300 rounded-md text-sm">
<span>-</span>
<input type="time" name="hours[{{ $dayNumber }}][close_time]" value="{{ old('hours.'.$dayNumber.'.close_time', $hour && $hour->close_time ? \Carbon\Carbon::parse($hour->close_time)->format('H:i') : '22:00') }}" :disabled="isClosed" class="w-full px-2 py-1 border border-gray-300 rounded-md text-sm">
</div>
</div>
<input type="hidden" name="hours[{{ $dayNumber }}][day_of_week]" value="{{ $dayNumber }}">
</div>
@endforeach
</div>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">Deskripsi Venue</label>
<textarea id="description" name="description" rows="5" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">{{ old('description', $venue->description) }}</textarea>
</div>
</div>
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Foto Sampul (Utama)</label>
<img id="current-image" src="{{ $venue->image ? asset('storage/' . $venue->image) : 'https://via.placeholder.com/300' }}" alt="{{ $venue->name }}" class="w-full h-48 object-cover rounded-lg bg-gray-100">
<input type="file" id="image" name="image" accept="image/*" class="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 mt-2">
</div>
<div class="pt-6 border-t">
<h3 class="text-lg font-medium text-gray-900 mb-2">Galeri Venue</h3>
<div id="gallery-container" class="grid grid-cols-3 sm:grid-cols-4 gap-4">
@foreach($venue->images as $image)
<div id="gallery-image-{{ $image->id }}" class="relative group">
<img src="{{ asset('storage/' . $image->path) }}" alt="Gallery Image" class="w-full h-24 object-cover rounded-lg">
<button type="button" onclick="deleteImage({{ $image->id }}, event)"
class="absolute top-1 right-1 bg-red-600 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
@endforeach
</div>
<p id="no-gallery-text" class="text-sm text-gray-500 mt-2 {{ $venue->images->isNotEmpty() ? 'hidden' : '' }}">Belum ada gambar di galeri.</p>
<div class="mt-4">
<label for="gallery_images" class="block text-sm font-medium text-gray-700 mb-2">Tambah Gambar ke Galeri</label>
<input type="file" id="gallery_images" name="gallery_images[]" accept="image/*" multiple class="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-green-50 file:text-green-700 hover:file:bg-green-100">
</div>
</div>
</div>
</div>
<!-- Description -->
<div class="mt-6">
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
Deskripsi Venue
</label>
<textarea id="description" name="description" rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('description') border-red-500 @enderror"
placeholder="Masukkan deskripsi venue (fasilitas, suasana, dll)">{{ old('description', $venue->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Action Buttons -->
<div class="flex items-center justify-end space-x-4 mt-8 pt-6 border-t border-gray-200">
<a href="{{ route('admin.venue.index') }}"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg font-medium transition-colors">
Batal
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Simpan Perubahan
</button>
<a href="{{ route('admin.venue.index') }}" class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg font-medium transition-colors">Batal</a>
<button type="submit" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">Simpan Perubahan</button>
</div>
</form>
</div>
</div>
<script>
function previewImage(input) {
const preview = document.getElementById('image-preview');
const previewImg = document.getElementById('preview-img');
function deleteImage(imageId, event) {
event.preventDefault();
if (!confirm('Apakah Anda yakin ingin menghapus gambar ini?')) return;
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
previewImg.src = e.target.result;
preview.classList.remove('hidden');
}
reader.readAsDataURL(input.files[0]);
} else {
preview.classList.add('hidden');
fetch(`/admin/venue-image/${imageId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json' // Memberitahu server kita ingin respons JSON
}
}
})
.then(response => {
// Cek jika respons dari server tidak OK (misal: error 403, 404, 500)
if (!response.ok) {
// Ubah respons menjadi error agar bisa ditangkap di .catch()
return response.json().then(err => { throw err; });
}
return response.json();
})
.then(data => {
if (data.success) {
const imageElement = document.getElementById(`gallery-image-${imageId}`);
imageElement.remove();
const galleryContainer = document.getElementById('gallery-container');
if (galleryContainer.children.length === 0) {
document.getElementById('no-gallery-text').classList.remove('hidden');
}
} else {
// Menampilkan pesan error dari server jika success = false
alert(data.message || 'Gagal menghapus gambar.');
}
})
.catch(error => {
// Menangkap error dari server (seperti 403, 500) atau error koneksi
console.error('Error:', error);
// Menampilkan pesan error yang lebih spesifik jika ada, jika tidak tampilkan pesan default
alert('Terjadi kesalahan: ' + (error.message || 'Periksa koneksi Anda.'));
});
}
</script>
@endsection

View File

@ -166,6 +166,14 @@ class="nav-item flex items-center px-3 py-2.5 rounded-lg {{ request()->routeIs('
</svg>
<span x-show="sidebarOpen">Kelola Booking</span>
</a>
<a href="{{ route('admin.bookings.scanner') }}"
class="nav-item flex items-center px-3 py-2.5 rounded-lg {{ request()->routeIs('admin.bookings.scanner') ? 'active' : '' }}">
{{-- Ikon Baru untuk QR Code --}}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4h6v6H4V4zM4 14h6v6H4v-6zM14 4h6v6h-6V4zM14 14h6v6h-6v-6z" />
</svg>
<span x-show="sidebarOpen">Scan QR</span>
</a>
<a href="{{ route('admin.revenues.index') }}"
class="nav-item flex items-center px-3 py-2.5 rounded-lg {{ request()->routeIs('admin.revenues.*') ? 'active' : '' }}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24"

View File

@ -1,4 +1,45 @@
@extends('layouts.main') @section('content') <div class="min-h-96 mx-4 md:w-3/4 md:mx-auto py-8">
@extends('layouts.main')
@section('content')
<div x-data="{
showModal: false,
qrCodeUrl: '',
venueName: '',
bookingDate: '',
bookingTime: ''
}"
x-on:keydown.escape.window="showModal = false; document.body.style.overflow = 'auto';"
class="min-h-screen bg-gray-100">
{{-- ======================================================= --}}
{{-- START: MODAL UNTUK MENAMPILKAN QR CODE --}}
{{-- ======================================================= --}}
<div x-show="showModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75 p-4"
x-cloak>
<div @click.away="showModal = false; document.body.style.overflow = 'auto';" class="bg-white rounded-lg shadow-xl p-6 max-w-sm w-full text-center">
<h3 class="text-lg font-bold text-gray-800" x-text="venueName"></h3>
<p class="text-sm text-gray-500 mb-4">
<span x-text="bookingDate"></span> - <span x-text="bookingTime"></span>
</p>
<div class="p-4 border rounded-lg bg-white">
<img :src="qrCodeUrl" alt="Booking QR Code" class="w-full h-auto">
</div>
<p class="text-xs text-gray-500 mt-4">Tunjukkan QR Code ini kepada kasir saat Anda tiba di venue.</p>
<div class="mt-6 flex space-x-4">
<button @click="showModal = false; document.body.style.overflow = 'auto';" class="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium">Tutup</button>
<a :href="qrCodeUrl" download="tiket-carimeja.svg" class="w-full flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">Download</a>
</div>
</div>
</div>
<div class="min-h-96 mx-4 md:w-3/4 md:mx-auto py-8">
<h1 class="text-2xl font-bold mb-6">Riwayat Bookingg</h1>
@if($bookings->isEmpty())
@ -64,17 +105,52 @@ class="px-3 py-1 rounded-full text-sm {{ $booking->start_time > now() ? 'bg-gree
@endif
</div>
@if($booking->start_time > now() && $booking->status == 'paid')
<div class="mt-4 flex justify-end space-x-4">
<a href="{{ route('venue', $booking->table->venue->name) }}"
class="text-blue-500 hover:underline">Lihat Venue</a>
<div class="border-t mt-4 pt-4 flex justify-end items-center space-x-4">
<a href="{{ route('venue', $booking->table->venue->name) }}" class="text-sm text-blue-600 hover:underline font-medium">
Lihat Venue
</a>
<a href="{{ route('booking.reschedule.form', $booking->id) }}"
class="text-orange-500 hover:underline">
Reschedule
</a>
</div>
@endif
{{-- Hanya tampilkan tombol jika status lunas --}}
@if($booking->status == 'paid')
{{-- Opsi untuk booking yang AKAN DATANG --}}
@if(now()->lt($booking->start_time))
{{-- Tombol Reschedule, dengan asumsi ada kolom reschedule_count di tabel bookings --}}
@if(isset($booking->reschedule_count) && $booking->reschedule_count < 1)
<a href="{{ route('booking.reschedule.form', $booking) }}" class="text-sm text-orange-600 hover:underline font-medium">
Reschedule
</a>
@endif
@endif
{{-- Opsi untuk booking yang SUDAH SELESAI --}}
@if(now()->gt($booking->end_time))
@if(!$booking->review)
<a href="{{ route('reviews.create', $booking) }}" class="text-sm text-green-600 hover:underline font-medium">
Beri Ulasan
</a>
@else
<span class="text-sm text-gray-500">Ulasan diberikan</span>
@endif
@endif
{{-- Tombol QR selalu muncul untuk booking lunas yang punya token --}}
@if($booking->validation_token)
<button @click="
showModal = true;
qrCodeUrl = '{{ route('booking.qrcode', $booking) }}';
venueName = '{{ addslashes($booking->table->venue->name) }}';
bookingDate = '{{ $booking->start_time->format("d M Y") }}';
bookingTime = '{{ $booking->start_time->format("H:i") }} - {{ $booking->end_time->format("H:i") }}';
document.body.style.overflow = 'hidden';
"
class="bg-blue-600 text-white text-sm font-bold px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
Lihat Tiket (QR)
</button>
@endif
@endif
</div>
</div>
</div>
@endforeach

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,75 @@
@extends('layouts.main')
@section('content')
<style>
/* CSS untuk rating bintang */
.rating {
display: inline-block;
direction: rtl; /* Bintang dari kanan ke kiri */
}
.rating input {
display: none;
}
.rating label {
font-size: 2.5rem;
color: #ddd;
cursor: pointer;
padding: 0 0.1em;
}
.rating input:checked ~ label,
.rating label:hover,
.rating label:hover ~ label {
color: #f5b301;
}
</style>
<div class="min-h-screen container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-8">
<h1 class="text-2xl font-bold text-gray-800 mb-2">Beri Ulasan Anda</h1>
<p class="text-gray-600 mb-6">
Untuk booking di **{{ $booking->table->venue->name }}** pada tanggal **{{ $booking->start_time->format('d M Y') }}**.
</p>
@if ($errors->any())
<div class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form action="{{ route('reviews.store') }}" method="POST">
@csrf
<input type="hidden" name="booking_id" value="{{ $booking->id }}">
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">Rating Anda *</label>
<div class="rating">
<input type="radio" id="star5" name="rating" value="5" required /><label for="star5"></label>
<input type="radio" id="star4" name="rating" value="4" /><label for="star4"></label>
<input type="radio" id="star3" name="rating" value="3" /><label for="star3"></label>
<input type="radio" id="star2" name="rating" value="2" /><label for="star2"></label>
<input type="radio" id="star1" name="rating" value="1" /><label for="star1"></label>
</div>
@error('rating') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="mb-6">
<label for="comment" class="block text-sm font-medium text-gray-700 mb-2">Ulasan Anda *</label>
<textarea name="comment" id="comment" rows="5"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Bagaimana pengalaman Anda di venue ini? Ceritakan di sini...">{{ old('comment') }}</textarea>
@error('comment') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div class="flex justify-end">
<button type="submit" class="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors">
Kirim Ulasan
</button>
</div>
</form>
</div>
</div>
@endsection

View File

@ -16,6 +16,7 @@
use App\Http\Controllers\superadmin\VenueManagementController;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Auth;
use App\Http\Controllers\ReviewController;
// Authentication Routes (dengan verifikasi email aktif)
Auth::routes(['verify' => true]);
@ -68,10 +69,15 @@
// Any sensitive operations that should still require password confirmation can go here
});
Route::post('/reviews', [ReviewController::class, 'store'])->name('reviews.store');
Route::get('/reviews/create/{booking}', [ReviewController::class, 'create'])->name('reviews.create');
Route::get('/booking/{booking}/qrcode', [BookingController::class, 'showQrCode'])->name('booking.qrcode');
});
// Admin routes (admin tetap perlu verified untuk keamanan)
Route::middleware(['auth', 'verified', 'is_admin'])->prefix('admin')->group(function () {
Route::middleware(['auth', 'verified', 'is_admin'])->prefix('admin')->group(function () {
Route::get('/', [AdminController::class, 'index'])->name('admin.dashboard');
// Admin Profile Routes
@ -81,12 +87,14 @@
// Booking management routes
Route::get('/bookings', [BookingsController::class, 'index'])->name('admin.bookings.index');
Route::get('/bookings/export', [BookingsController::class, 'export'])->name('admin.bookings.export');
Route::get('/bookings/{id}', [BookingsController::class, 'show'])->name('admin.bookings.show');
Route::get('/bookings/{id}/edit', [BookingsController::class, 'edit'])->name('admin.bookings.edit');
Route::put('/bookings/{id}', [BookingsController::class, 'update'])->name('admin.bookings.update');
Route::patch('/bookings/{id}/complete', [BookingsController::class, 'complete'])->name('admin.bookings.complete');
Route::patch('/bookings/{id}/cancel', [BookingsController::class, 'cancel'])->name('admin.bookings.cancel');
Route::get('/bookings/export', [BookingsController::class, 'export'])->name('admin.bookings.export');
Route::get('/bookings/scan', [BookingsController::class, 'showScanner'])->name('admin.bookings.scanner'); // <-- Pindahkan ke atas
Route::get('/bookings/validate/{token}', [BookingsController::class, 'validateBooking'])->name('admin.bookings.validate'); // <-- Pindahkan ke atas
Route::get('/bookings/{id}', [BookingsController::class, 'show'])->name('admin.bookings.show'); // <-- Route dengan {id} sekarang di bawah
Route::get('/bookings/{id}/edit', [BookingsController::class, 'edit'])->name('admin.bookings.edit');
Route::put('/bookings/{id}', [BookingsController::class, 'update'])->name('admin.bookings.update');
Route::patch('/bookings/{id}/complete', [BookingsController::class, 'complete'])->name('admin.bookings.complete');
Route::patch('/bookings/{id}/cancel', [BookingsController::class, 'cancel'])->name('admin.bookings.cancel');
// Table management routes
Route::get('/tables', [TableController::class, 'index'])->name('admin.tables.index');
@ -101,11 +109,14 @@
Route::get('/venue/edit', [AdminVenueController::class, 'edit'])->name('admin.venue.edit');
Route::put('/venue/update', [AdminVenueController::class, 'update'])->name('admin.venue.update');
Route::post('/venue/toggle-status', [AdminVenueController::class, 'toggleStatus'])->name('admin.venue.toggle-status');
Route::delete('/venue-image/{image}', [AdminVenueController::class, 'destroyImage'])->name('admin.venue.image.destroy');
// Revenue management routes
Route::get('/revenues', [RevenueController::class, 'index'])->name('admin.revenues.index');
Route::get('/revenues/detail/{tableId}', [RevenueController::class, 'detail'])->name('admin.revenues.detail');
Route::get('/revenues/export', [RevenueController::class, 'export'])->name('admin.revenues.export');
});
// Superadmin routes