Bagian 1: Pendahuluan - Pentingnya Halaman Admin yang Terstruktur
Halo teman-teman developer! Kali ini saya akan mengajak kalian untuk latihan membuat halaman admin beserta Content Management System (CMS) pada projek website booking lapangan tenis dan padel menggunakan Laravel dan Filament. Saya sangat excited untuk berbagi pengalaman ini karena projek booking lapangan olahraga merupakan salah satu jenis aplikasi yang sangat dibutuhkan saat ini, mengingat semakin banyaknya tempat olahraga yang bermunculan dan membutuhkan sistem reservasi yang efisien.
Sebelum saya masuk ke tahap coding, saya ingin terlebih dahulu menjelaskan mengapa memiliki halaman admin yang terstruktur itu sangat penting untuk projek seperti ini. Bayangkan saja, ketika kita mengelola sebuah venue olahraga yang memiliki beberapa lapangan tenis dan padel, ada banyak sekali hal yang harus diurus setiap harinya. Mulai dari memastikan jadwal booking tidak bentrok, mengecek ketersediaan lapangan di jam-jam tertentu, mencatat data member yang sudah mendaftar, sampai memproses pembayaran dan membuat laporan pendapatan.
Saya pernah melihat beberapa venue olahraga yang masih menggunakan cara manual untuk mengelola booking, seperti mencatat di buku atau spreadsheet Excel. Cara ini memang bisa berjalan, tetapi sangat rentan terhadap kesalahan dan memakan waktu yang lama. Misalnya, ketika ada dua customer yang ingin booking di jam yang sama, staff harus mengecek secara manual apakah lapangan tersedia atau tidak. Belum lagi kalau ada pembatalan mendadak, staff harus mencoret catatan dan menginformasikan ke customer lain yang mungkin sedang menunggu slot tersebut.
Dengan membangun halaman admin yang terstruktur menggunakan Laravel dan Filament, saya bisa membuat sistem yang mampu menangani semua kebutuhan tersebut secara otomatis. Saya akan membuat fitur pengelolaan lapangan dimana admin bisa menambahkan data lapangan baru, mengatur tipe lapangan apakah itu tenis outdoor, tenis indoor, atau padel, serta menentukan harga sewa per jam untuk masing-masing lapangan. Sistem ini juga akan menampilkan status ketersediaan secara real-time sehingga tidak akan terjadi double booking.
Selain itu, saya juga akan membuat fitur pengelolaan jadwal dan slot waktu yang sangat penting untuk bisnis seperti ini. Admin bisa mengatur jam operasional venue, membuat slot waktu booking misalnya per satu jam atau per dua jam, dan bahkan bisa membuat harga berbeda untuk jam-jam tertentu seperti prime time di sore hari atau weekend yang biasanya lebih ramai.
Untuk pengelolaan data member, saya akan membuat sistem yang memungkinkan admin melihat seluruh data pelanggan yang sudah terdaftar, riwayat booking mereka, dan bahkan memberikan membership khusus dengan benefit tertentu seperti diskon atau prioritas booking. Ini sangat berguna untuk membangun loyalitas customer dan meningkatkan repeat order.
Bagian yang tidak kalah penting adalah pengelolaan transaksi dan pembayaran. Saya akan mengintegrasikan sistem dengan payment gateway Midtrans sehingga customer bisa langsung membayar secara online setelah melakukan booking. Admin juga bisa melihat status pembayaran setiap transaksi, apakah sudah lunas, pending, atau dibatalkan. Dengan begitu, tidak ada lagi cerita customer yang sudah booking tapi lupa bayar dan tiba-tiba datang ke venue.
Saya juga akan membuat fitur laporan yang comprehensive. Admin dan manager bisa melihat laporan pendapatan harian, mingguan, atau bulanan. Mereka juga bisa menganalisis lapangan mana yang paling laris, jam berapa yang paling ramai, dan siapa saja member yang paling aktif. Data-data ini sangat berharga untuk pengambilan keputusan bisnis, misalnya apakah perlu menambah lapangan baru atau mengadakan promo di jam-jam sepi.
Dengan menggunakan Filament sebagai admin panel builder, saya tidak perlu membuat tampilan admin dari nol. Filament sudah menyediakan komponen-komponen yang sangat lengkap dan tampilannya sudah modern serta responsive. Saya tinggal fokus pada logic bisnis dan konfigurasi, sementara Filament akan menghandle tampilan form, tabel, filter, dan berbagai fitur lainnya.
Kombinasi dengan Spatie Permission akan memungkinkan saya untuk mengatur hak akses yang berbeda untuk setiap role pengguna. Misalnya, staff operasional hanya bisa melihat jadwal booking hari ini dan mengkonfirmasi kehadiran customer, kasir bisa memproses pembayaran manual, sementara manager punya akses penuh ke semua fitur termasuk laporan keuangan. Dengan pembagian role seperti ini, operasional venue akan berjalan lebih aman dan terorganisir.
Mari kita mulai perjalanan membangun website booking lapangan tenis dan padel yang profesional!
Bagian 2: Membuat Projek Laravel Baru dan Konfigurasi Database
Sekarang saya akan mulai masuk ke tahap teknis pertama, yaitu membuat projek Laravel baru dan mengatur koneksi database. Saya akan memandu teman-teman step by step agar tidak ada yang terlewat dan projek bisa berjalan dengan lancar dari awal.
Membuat Projek Laravel dengan Composer
Pertama-tama, saya harus memastikan bahwa di komputer saya sudah terinstall PHP versi 8.2 atau lebih tinggi, Composer, dan MySQL. Kalau teman-teman belum menginstall tools tersebut, saya sarankan untuk menginstallnya terlebih dahulu karena ini adalah requirement dasar untuk menjalankan Laravel 12.
Setelah semua siap, saya buka terminal atau command prompt, lalu saya navigasikan ke folder dimana saya ingin menyimpan projek ini. Misalnya saya ingin menyimpannya di folder Projects, maka saya ketik:
cd ~/Projects
Selanjutnya saya bikin projek Laravel baru dengan menjalankan perintah Composer berikut:
composer create-project laravel/laravel booking-lapangan-tenis
Saya beri nama projeknya booking-lapangan-tenis agar mudah diingat dan mencerminkan isi dari aplikasi yang akan saya bangun. Perintah ini akan mengunduh Laravel versi terbaru beserta semua dependency yang dibutuhkan. Proses ini biasanya memakan waktu beberapa menit tergantung kecepatan internet.
Setelah proses instalasi selesai, saya masuk ke direktori projek yang baru saja dibuat:
cd booking-lapangan-tenis
Untuk memastikan instalasi berhasil, saya coba jalankan development server Laravel dengan perintah:
php artisan serve
Kalau berhasil, saya akan melihat pesan bahwa server berjalan di http://127.0.0.1:8000. Saya buka alamat tersebut di browser dan akan muncul halaman welcome Laravel. Ini menandakan projek Laravel saya sudah siap untuk dikembangkan lebih lanjut.
Membuat Database MySQL
Sebelum saya mengatur file .env, saya perlu membuat database terlebih dahulu di MySQL. Saya buka aplikasi MySQL client seperti phpMyAdmin, TablePlus, atau MySQL Workbench. Teman-teman juga bisa menggunakan command line kalau lebih nyaman.
Kalau saya menggunakan command line, saya ketik perintah berikut untuk masuk ke MySQL:
mysql -u root -p
Setelah memasukkan password, saya bikin database baru dengan nama yang sesuai dengan projek:
CREATE DATABASE booking_lapangan_tenis;
Saya menggunakan underscore sebagai pemisah kata karena ini adalah konvensi yang umum digunakan untuk penamaan database. Setelah database berhasil dibuat, saya keluar dari MySQL dengan mengetik exit.
Mengatur File .env untuk Koneksi Database
Sekarang saya akan mengatur konfigurasi database di file .env. File ini terletak di root folder projek Laravel dan berisi berbagai konfigurasi environment untuk aplikasi. Saya buka file .env menggunakan code editor favorit saya, bisa Visual Studio Code, Sublime Text, atau editor lainnya.
Di dalam file .env, saya cari bagian yang mengatur koneksi database. Secara default, Laravel sudah menyediakan template konfigurasi database yang perlu saya sesuaikan. Berikut adalah konfigurasi lengkap yang saya atur:
APP_NAME="Booking Lapangan Tenis"
APP_ENV=local
APP_KEY=base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
APP_DEBUG=true
APP_TIMEZONE=Asia/Jakarta
APP_URL=http://localhost:8000
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=booking_lapangan_tenis
DB_USERNAME=root
DB_PASSWORD=password_mysql_saya
Saya jelaskan satu per satu konfigurasi yang saya atur di atas:
Untuk bagian APP_NAME, saya isi dengan nama aplikasi yaitu "Booking Lapangan Tenis". Nama ini akan muncul di berbagai tempat seperti judul halaman dan email notifikasi.
Untuk APP_ENV, saya set ke local karena saat ini saya sedang dalam tahap development. Nanti ketika sudah deploy ke server production, saya akan mengubahnya menjadi production.
Untuk APP_DEBUG, saya set ke true agar saya bisa melihat pesan error secara detail saat development. Ini sangat membantu untuk debugging. Tapi ingat, saat di production harus diubah ke false agar error detail tidak terlihat oleh user.
Untuk APP_TIMEZONE, saya set ke Asia/Jakarta karena target user saya adalah orang Indonesia. Dengan setting ini, semua waktu di aplikasi akan menggunakan zona waktu WIB.
Selanjutnya untuk konfigurasi database:
DB_CONNECTION saya isi dengan mysql karena saya menggunakan MySQL sebagai database. Laravel juga mendukung database lain seperti PostgreSQL, SQLite, dan SQL Server.
DB_HOST saya isi dengan 127.0.0.1 yang merupakan alamat localhost. Ini karena database MySQL saya berjalan di komputer yang sama dengan aplikasi Laravel.
DB_PORT saya isi dengan 3306 yang merupakan port default untuk MySQL. Kalau teman-teman menggunakan port yang berbeda, silakan disesuaikan.
DB_DATABASE saya isi dengan nama database yang sudah saya buat tadi yaitu booking_lapangan_tenis.
DB_USERNAME saya isi dengan username MySQL saya, dalam contoh ini saya menggunakan root. Di production, saya sangat menyarankan untuk membuat user database khusus dengan permission yang terbatas.
DB_PASSWORD saya isi dengan password MySQL saya. Pastikan password ini benar agar Laravel bisa terhubung ke database.
Mengetes Koneksi Database
Setelah konfigurasi selesai, saya perlu memastikan bahwa Laravel sudah bisa terhubung ke database dengan benar. Saya jalankan perintah migration bawaan Laravel untuk mengetes koneksi:
php artisan migrate
Kalau konfigurasi sudah benar, saya akan melihat output seperti ini:
INFO Preparing database.
Creating migration table ................................... 38.45ms DONE
INFO Running migrations.
0001_01_01_000000_create_users_table ....................... 65.23ms DONE
0001_01_01_000001_create_cache_table ....................... 21.87ms DONE
0001_01_01_000002_create_jobs_table ........................ 48.92ms DONE
Ini menandakan Laravel sudah berhasil terhubung ke database dan menjalankan migration default. Saya bisa mengecek di phpMyAdmin atau MySQL client lainnya, akan ada beberapa tabel baru yang sudah dibuat seperti users, cache, jobs, dan lainnya.
Konfigurasi Tambahan yang Saya Rekomendasikan
Selain konfigurasi database, ada beberapa konfigurasi tambahan di file .env yang saya atur untuk keperluan projek ini:
CACHE_STORE=database
QUEUE_CONNECTION=database
SESSION_DRIVER=database
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
[email protected]
MAIL_PASSWORD=app_password_gmail
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"
Saya set CACHE_STORE, QUEUE_CONNECTION, dan SESSION_DRIVER ke database agar semua data cache, queue, dan session disimpan di database. Ini memudahkan pengelolaan dan lebih reliable dibanding menyimpan di file.
Untuk konfigurasi mail, saya sudah menyiapkan template untuk SMTP Gmail. Nanti konfigurasi ini akan saya gunakan ketika membuat fitur notifikasi email otomatis di bagian selanjutnya.
Membuat Storage Link
Satu hal lagi yang sering terlupakan adalah membuat symbolic link untuk storage. Ini diperlukan agar file yang diupload bisa diakses secara public. Saya jalankan perintah:
php artisan storage:link
Dengan ini, folder storage/app/public akan bisa diakses melalui URL public/storage. Nanti ini akan berguna ketika saya mengupload foto lapangan atau dokumen lainnya.
Generate Application Key
Sebenarnya ketika membuat projek baru dengan Composer, Laravel sudah otomatis generate application key. Tapi kalau karena suatu hal key belum ada atau ingin digenerate ulang, saya bisa menjalankan:
php artisan key:generate
Application key ini sangat penting untuk enkripsi data di Laravel, jadi pastikan key ini sudah ada dan jangan pernah di-share ke public.
Sampai di sini, projek Laravel saya sudah siap dengan konfigurasi database yang benar. Di bagian selanjutnya, saya akan mulai membuat file migration dan model untuk tabel-tabel yang dibutuhkan dalam sistem booking lapangan tenis dan padel ini.
Bagian 3: Membuat Migration dan Model dengan Fillable dan Relationship
Sekarang saya masuk ke bagian yang sangat penting dalam membangun aplikasi Laravel, yaitu membuat struktur database melalui migration dan model. Saya akan membuat beberapa tabel yang saling berhubungan untuk mengelola sistem booking lapangan tenis dan padel ini. Tabel-tabel yang akan saya buat adalah court_types, courts, time_slots, members, dan bookings.
Memahami Struktur Database yang Akan Dibuat
Sebelum saya mulai coding, saya ingin menjelaskan terlebih dahulu bagaimana hubungan antar tabel yang akan saya buat. Pemahaman ini penting agar teman-teman bisa mengikuti logic yang saya gunakan.
Jadi begini, saya punya tabel court_types yang berisi jenis-jenis lapangan seperti Tenis Indoor, Tenis Outdoor, atau Padel. Setiap tipe lapangan bisa memiliki banyak lapangan, misalnya Tenis Indoor punya Lapangan A, Lapangan B, dan seterusnya. Ini adalah relasi one-to-many antara court_types dan courts.
Kemudian saya punya tabel time_slots yang berisi slot waktu booking, misalnya jam 08:00-09:00, 09:00-10:00, dan seterusnya. Setiap slot waktu bisa digunakan untuk booking di lapangan manapun.
Tabel members berisi data pelanggan yang sudah mendaftar sebagai member. Member ini bisa melakukan banyak booking, jadi ada relasi one-to-many antara members dan bookings.
Terakhir, tabel bookings adalah tabel utama yang mencatat setiap reservasi. Tabel ini berelasi dengan courts, time_slots, dan members. Saya juga akan mencatat tanggal booking, status pembayaran, dan total harga di tabel ini.
Membuat Migration untuk Tabel court_types
Saya mulai dengan membuat migration untuk tabel court_types terlebih dahulu karena tabel ini akan menjadi parent dari tabel courts. Saya buka terminal dan jalankan perintah Artisan:
php artisan make:migration create_court_types_table
Setelah perintah dijalankan, Laravel akan membuat file migration baru di folder database/migrations. Saya buka file tersebut dan modifikasi isinya menjadi seperti ini:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('court_types', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->string('icon')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('court_types');
}
};
Saya jelaskan kolom-kolom yang saya buat di tabel ini. Kolom name untuk menyimpan nama tipe lapangan seperti "Tenis Indoor" atau "Padel". Kolom description saya buat nullable karena deskripsi bersifat opsional. Kolom icon untuk menyimpan path icon atau emoji yang merepresentasikan tipe lapangan. Dan kolom is_active untuk menandai apakah tipe lapangan ini masih aktif atau tidak.
Membuat Migration untuk Tabel courts
Selanjutnya saya bikin migration untuk tabel courts yang akan menyimpan data lapangan:
php artisan make:migration create_courts_table
Saya buka file migration yang baru dibuat dan isi dengan kode berikut:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('courts', function (Blueprint $table) {
$table->id();
$table->foreignId('court_type_id')->constrained('court_types')->onDelete('cascade');
$table->string('name');
$table->text('description')->nullable();
$table->string('image')->nullable();
$table->decimal('price_per_hour', 10, 2);
$table->decimal('weekend_price_per_hour', 10, 2)->nullable();
$table->integer('capacity')->default(4);
$table->json('facilities')->nullable();
$table->boolean('is_available')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('courts');
}
};
Di tabel ini saya menambahkan kolom court_type_id sebagai foreign key yang mereferensikan ke tabel court_types. Saya menggunakan onDelete('cascade') agar ketika sebuah tipe lapangan dihapus, semua lapangan yang terkait juga ikut terhapus.
Kolom price_per_hour untuk harga sewa per jam di hari biasa, sedangkan weekend_price_per_hour untuk harga di akhir pekan yang biasanya lebih mahal. Saya juga menambahkan kolom facilities dengan tipe JSON untuk menyimpan daftar fasilitas seperti pencahayaan, AC, atau locker room dalam format array.
Membuat Migration untuk Tabel time_slots
Sekarang saya bikin migration untuk tabel time_slots:
php artisan make:migration create_time_slots_table
Isi file migration-nya seperti ini:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('time_slots', function (Blueprint $table) {
$table->id();
$table->time('start_time');
$table->time('end_time');
$table->string('label')->nullable();
$table->boolean('is_prime_time')->default(false);
$table->decimal('prime_time_surcharge', 10, 2)->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('time_slots');
}
};
Saya menambahkan kolom is_prime_time untuk menandai apakah slot waktu ini termasuk jam sibuk atau tidak. Biasanya jam 17:00-21:00 adalah prime time karena banyak orang pulang kerja dan ingin berolahraga. Kolom prime_time_surcharge untuk menyimpan biaya tambahan saat prime time. Kolom label opsional untuk memberikan label khusus seperti "Pagi", "Siang", atau "Malam".
Membuat Migration untuk Tabel members
Selanjutnya saya bikin migration untuk tabel members:
php artisan make:migration create_members_table
Isi file migration-nya:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('members', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained('users')->onDelete('set null');
$table->string('name');
$table->string('email')->unique();
$table->string('phone');
$table->text('address')->nullable();
$table->date('date_of_birth')->nullable();
$table->enum('gender', ['male', 'female'])->nullable();
$table->string('profile_photo')->nullable();
$table->enum('membership_type', ['regular', 'silver', 'gold', 'platinum'])->default('regular');
$table->date('membership_expired_at')->nullable();
$table->decimal('total_spent', 12, 2)->default(0);
$table->integer('total_bookings')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('members');
}
};
Di tabel members ini saya menambahkan kolom user_id yang nullable dan berelasi dengan tabel users bawaan Laravel. Ini memungkinkan member untuk memiliki akun login, tapi juga memungkinkan admin untuk mendaftarkan member tanpa harus membuat akun user terlebih dahulu.
Saya juga menambahkan kolom membership_type dengan enum untuk membedakan level membership. Member platinum misalnya bisa mendapat diskon lebih besar atau prioritas booking. Kolom total_spent dan total_bookings akan di-update otomatis setiap ada transaksi baru, berguna untuk analisis customer.
Membuat Migration untuk Tabel bookings
Terakhir, saya bikin migration untuk tabel bookings yang merupakan tabel inti dari aplikasi ini:
php artisan make:migration create_bookings_table
Isi file migration-nya:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bookings', function (Blueprint $table) {
$table->id();
$table->string('booking_code')->unique();
$table->foreignId('court_id')->constrained('courts')->onDelete('cascade');
$table->foreignId('member_id')->constrained('members')->onDelete('cascade');
$table->foreignId('time_slot_id')->constrained('time_slots')->onDelete('cascade');
$table->date('booking_date');
$table->decimal('base_price', 10, 2);
$table->decimal('additional_charges', 10, 2)->default(0);
$table->decimal('discount', 10, 2)->default(0);
$table->decimal('total_price', 10, 2);
$table->enum('payment_status', ['pending', 'paid', 'failed', 'refunded', 'expired'])->default('pending');
$table->enum('booking_status', ['pending', 'confirmed', 'completed', 'cancelled', 'no_show'])->default('pending');
$table->string('payment_method')->nullable();
$table->string('transaction_id')->nullable();
$table->timestamp('paid_at')->nullable();
$table->text('notes')->nullable();
$table->text('cancellation_reason')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->timestamps();
$table->unique(['court_id', 'time_slot_id', 'booking_date'], 'unique_booking');
});
}
public function down(): void
{
Schema::dropIfExists('bookings');
}
};
Tabel bookings ini cukup kompleks karena menyimpan banyak informasi. Saya menambahkan booking_code yang unique untuk referensi booking yang mudah dibaca customer, misalnya "BK-20241215-001".
Yang penting di sini adalah saya membuat unique constraint pada kombinasi court_id, time_slot_id, dan booking_date. Ini untuk memastikan tidak ada double booking pada lapangan yang sama, di slot waktu yang sama, dan di tanggal yang sama.
Saya juga memisahkan payment_status dan booking_status karena keduanya punya lifecycle yang berbeda. Misalnya, booking bisa saja sudah confirmed tapi payment masih pending kalau customer memilih bayar di tempat.
Menjalankan Migration
Setelah semua file migration selesai dibuat, saya jalankan perintah migrate untuk membuat tabel-tabel tersebut di database:
php artisan migrate
Saya akan melihat output yang menunjukkan semua migration berhasil dijalankan:
INFO Running migrations.
2024_12_15_100000_create_court_types_table ................. 32.45ms DONE
2024_12_15_100001_create_courts_table ...................... 28.67ms DONE
2024_12_15_100002_create_time_slots_table .................. 18.92ms DONE
2024_12_15_100003_create_members_table ..................... 35.21ms DONE
2024_12_15_100004_create_bookings_table .................... 42.15ms DONE
Membuat Model CourtType
Sekarang saya akan membuat model untuk setiap tabel. Saya mulai dengan model CourtType:
php artisan make:model CourtType
Saya buka file app/Models/CourtType.php dan modifikasi isinya:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class CourtType extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
'icon',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function courts(): HasMany
{
return $this->hasMany(Court::class);
}
public function activeCourts(): HasMany
{
return $this->hasMany(Court::class)->where('is_available', true);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
}
Di model ini saya mendefinisikan properti $fillable yang berisi kolom-kolom yang boleh diisi secara mass assignment. Saya juga menambahkan $casts untuk mengkonversi kolom is_active menjadi boolean secara otomatis.
Untuk relationship, saya membuat method courts() yang mengembalikan relasi hasMany ke model Court. Saya juga membuat method activeCourts() sebagai shortcut untuk mendapatkan hanya lapangan yang tersedia. Dan ada scope scopeActive() untuk memfilter tipe lapangan yang aktif saja.
Membuat Model Court
Selanjutnya saya bikin model Court:
php artisan make:model Court
Isi file app/Models/Court.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Court extends Model
{
use HasFactory;
protected $fillable = [
'court_type_id',
'name',
'description',
'image',
'price_per_hour',
'weekend_price_per_hour',
'capacity',
'facilities',
'is_available',
];
protected $casts = [
'price_per_hour' => 'decimal:2',
'weekend_price_per_hour' => 'decimal:2',
'facilities' => 'array',
'is_available' => 'boolean',
];
public function courtType(): BelongsTo
{
return $this->belongsTo(CourtType::class);
}
public function bookings(): HasMany
{
return $this->hasMany(Booking::class);
}
public function getPriceForDate($date): float
{
$dayOfWeek = \\Carbon\\Carbon::parse($date)->dayOfWeek;
$isWeekend = in_array($dayOfWeek, [0, 6]);
if ($isWeekend && $this->weekend_price_per_hour) {
return $this->weekend_price_per_hour;
}
return $this->price_per_hour;
}
public function isAvailableAt($date, $timeSlotId): bool
{
return !$this->bookings()
->where('booking_date', $date)
->where('time_slot_id', $timeSlotId)
->whereNotIn('booking_status', ['cancelled', 'no_show'])
->exists();
}
public function scopeAvailable($query)
{
return $query->where('is_available', true);
}
}
Di model Court ini saya menambahkan cast 'facilities' => 'array' agar kolom JSON facilities otomatis dikonversi menjadi array PHP saat diakses.
Saya juga membuat beberapa helper method yang sangat berguna. Method getPriceForDate() untuk mendapatkan harga berdasarkan apakah tanggal tersebut weekend atau bukan. Method isAvailableAt() untuk mengecek apakah lapangan tersedia pada tanggal dan slot waktu tertentu.
Membuat Model TimeSlot
Sekarang saya bikin model TimeSlot:
php artisan make:model TimeSlot
Isi file app/Models/TimeSlot.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class TimeSlot extends Model
{
use HasFactory;
protected $fillable = [
'start_time',
'end_time',
'label',
'is_prime_time',
'prime_time_surcharge',
'is_active',
];
protected $casts = [
'start_time' => 'datetime:H:i',
'end_time' => 'datetime:H:i',
'is_prime_time' => 'boolean',
'prime_time_surcharge' => 'decimal:2',
'is_active' => 'boolean',
];
public function bookings(): HasMany
{
return $this->hasMany(Booking::class);
}
public function getFormattedSlotAttribute(): string
{
return $this->start_time->format('H:i') . ' - ' . $this->end_time->format('H:i');
}
public function getDisplayNameAttribute(): string
{
$slot = $this->formatted_slot;
if ($this->label) {
return "{$this->label} ({$slot})";
}
return $slot;
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopePrimeTime($query)
{
return $query->where('is_prime_time', true);
}
}
Di model TimeSlot ini saya menambahkan accessor getFormattedSlotAttribute() yang mengembalikan format waktu yang mudah dibaca seperti "08:00 - 09:00". Accessor getDisplayNameAttribute() menggabungkan label dengan waktu jika label tersedia.
Membuat Model Member
Selanjutnya saya bikin model Member:
php artisan make:model Member
Isi file app/Models/Member.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Member extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'name',
'email',
'phone',
'address',
'date_of_birth',
'gender',
'profile_photo',
'membership_type',
'membership_expired_at',
'total_spent',
'total_bookings',
'is_active',
];
protected $casts = [
'date_of_birth' => 'date',
'membership_expired_at' => 'date',
'total_spent' => 'decimal:2',
'is_active' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function bookings(): HasMany
{
return $this->hasMany(Booking::class);
}
public function completedBookings(): HasMany
{
return $this->hasMany(Booking::class)->where('booking_status', 'completed');
}
public function getDiscountPercentageAttribute(): int
{
return match($this->membership_type) {
'platinum' => 20,
'gold' => 15,
'silver' => 10,
default => 0,
};
}
public function isMembershipActive(): bool
{
if (!$this->membership_expired_at) {
return $this->membership_type === 'regular';
}
return $this->membership_expired_at->isFuture();
}
public function updateStatistics(): void
{
$this->total_bookings = $this->completedBookings()->count();
$this->total_spent = $this->completedBookings()->sum('total_price');
$this->save();
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeByMembershipType($query, $type)
{
return $query->where('membership_type', $type);
}
}
Di model Member ini saya menambahkan accessor getDiscountPercentageAttribute() yang mengembalikan persentase diskon berdasarkan tipe membership. Method isMembershipActive() untuk mengecek apakah membership masih berlaku atau sudah expired.
Saya juga membuat method updateStatistics() yang akan dipanggil setiap kali ada booking baru yang selesai untuk mengupdate total booking dan total spending dari member tersebut.
Membuat Model Booking
Terakhir, saya bikin model Booking:
php artisan make:model Booking
Isi file app/Models/Booking.php:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Support\\Str;
class Booking extends Model
{
use HasFactory;
protected $fillable = [
'booking_code',
'court_id',
'member_id',
'time_slot_id',
'booking_date',
'base_price',
'additional_charges',
'discount',
'total_price',
'payment_status',
'booking_status',
'payment_method',
'transaction_id',
'paid_at',
'notes',
'cancellation_reason',
'cancelled_at',
];
protected $casts = [
'booking_date' => 'date',
'base_price' => 'decimal:2',
'additional_charges' => 'decimal:2',
'discount' => 'decimal:2',
'total_price' => 'decimal:2',
'paid_at' => 'datetime',
'cancelled_at' => 'datetime',
];
protected static function boot()
{
parent::boot();
static::creating(function ($booking) {
if (empty($booking->booking_code)) {
$booking->booking_code = self::generateBookingCode();
}
});
}
public static function generateBookingCode(): string
{
$prefix = 'BK';
$date = now()->format('Ymd');
$random = strtoupper(Str::random(4));
return "{$prefix}-{$date}-{$random}";
}
public function court(): BelongsTo
{
return $this->belongsTo(Court::class);
}
public function member(): BelongsTo
{
return $this->belongsTo(Member::class);
}
public function timeSlot(): BelongsTo
{
return $this->belongsTo(TimeSlot::class);
}
public function calculateTotalPrice(): float
{
$basePrice = $this->base_price;
$additionalCharges = $this->additional_charges ?? 0;
$discount = $this->discount ?? 0;
return $basePrice + $additionalCharges - $discount;
}
public function markAsPaid($paymentMethod, $transactionId = null): void
{
$this->update([
'payment_status' => 'paid',
'payment_method' => $paymentMethod,
'transaction_id' => $transactionId,
'paid_at' => now(),
'booking_status' => 'confirmed',
]);
$this->member->updateStatistics();
}
public function cancel($reason = null): void
{
$this->update([
'booking_status' => 'cancelled',
'cancellation_reason' => $reason,
'cancelled_at' => now(),
]);
}
public function complete(): void
{
$this->update([
'booking_status' => 'completed',
]);
$this->member->updateStatistics();
}
public function isPending(): bool
{
return $this->payment_status === 'pending';
}
public function isPaid(): bool
{
return $this->payment_status === 'paid';
}
public function isConfirmed(): bool
{
return $this->booking_status === 'confirmed';
}
public function isCancelled(): bool
{
return $this->booking_status === 'cancelled';
}
public function scopePending($query)
{
return $query->where('payment_status', 'pending');
}
public function scopePaid($query)
{
return $query->where('payment_status', 'paid');
}
public function scopeConfirmed($query)
{
return $query->where('booking_status', 'confirmed');
}
public function scopeForDate($query, $date)
{
return $query->where('booking_date', $date);
}
public function scopeUpcoming($query)
{
return $query->where('booking_date', '>=', now()->toDateString())
->whereNotIn('booking_status', ['cancelled', 'completed']);
}
}
Model Booking ini adalah yang paling kompleks karena merupakan pusat dari aplikasi. Saya menggunakan boot method untuk auto-generate booking code setiap kali booking baru dibuat.
Saya juga membuat beberapa method helper seperti markAsPaid(), cancel(), dan complete() untuk mengubah status booking dengan mudah. Method-method ini juga akan mengupdate statistik member secara otomatis.
Berbagai scope seperti scopePending(), scopePaid(), scopeForDate(), dan scopeUpcoming() akan sangat membantu saat kita membuat query untuk menampilkan data di halaman admin nanti.
Menambahkan Relationship di Model User
Jangan lupa, saya juga perlu menambahkan relationship di model User bawaan Laravel. Saya buka file app/Models/User.php dan tambahkan method berikut:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
class User extends Authenticatable
{
use HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function member(): HasOne
{
return $this->hasOne(Member::class);
}
public function isMember(): bool
{
return $this->member !== null;
}
}
Dengan menambahkan relationship member() di model User, saya bisa dengan mudah mengakses data member dari user yang sedang login.
Sampai di sini saya sudah selesai membuat semua migration dan model yang dibutuhkan untuk sistem booking lapangan tenis dan padel. Di bagian selanjutnya, saya akan menginstall Filament dan membuat akun admin untuk mengelola data-data ini melalui halaman admin yang interaktif.
Bagian 4: Menginstall Filament dan Membuat Akun Admin
Sekarang saya masuk ke bagian yang sangat menarik, yaitu menginstall Filament sebagai admin panel untuk projek booking lapangan tenis dan padel ini. Filament adalah package Laravel yang luar biasa powerful untuk membangun halaman admin dengan tampilan modern dan fitur yang sangat lengkap. Saya sangat suka menggunakan Filament karena bisa menghemat waktu development yang signifikan.
Mengapa Saya Memilih Filament
Sebelum saya mulai instalasi, saya ingin berbagi alasan kenapa saya memilih Filament dibandingkan admin panel lainnya. Pertama, Filament dibangun khusus untuk Laravel sehingga integrasinya sangat seamless dengan Eloquent model yang sudah saya buat sebelumnya. Kedua, Filament menggunakan Livewire dan Alpine.js yang membuat tampilan admin menjadi reaktif tanpa perlu menulis JavaScript yang kompleks. Ketiga, dokumentasinya sangat lengkap dan komunitasnya sangat aktif, jadi kalau saya menemui masalah, biasanya solusinya mudah ditemukan.
Menginstall Package Filament
Untuk menginstall Filament, saya buka terminal dan pastikan sudah berada di direktori projek Laravel. Kemudian saya jalankan perintah Composer berikut:
composer require filament/filament:"^3.2" -W
Saya menggunakan flag -W untuk memastikan semua dependency di-update sesuai kebutuhan Filament. Proses instalasi ini membutuhkan waktu beberapa menit karena Filament memiliki cukup banyak dependency.
Setelah package terinstall, saya perlu menjalankan perintah instalasi Filament untuk mempublish asset dan konfigurasi yang dibutuhkan:
php artisan filament:install --panels
Perintah ini akan melakukan beberapa hal secara otomatis. Pertama, Filament akan membuat file konfigurasi di config/filament.php. Kedua, Filament akan mempublish asset seperti CSS dan JavaScript ke folder public. Ketiga, Filament akan membuat AdminPanelProvider di folder app/Providers/Filament.
Saat menjalankan perintah ini, Filament akan menanyakan beberapa pertanyaan. Saya akan menjawab seperti berikut:
What is the ID? [admin]
> admin
Where do you want to create it? [app/Providers/Filament/AdminPanelProvider.php]
> (tekan Enter untuk menggunakan default)
Melihat Struktur File yang Dibuat Filament
Setelah instalasi selesai, saya cek struktur folder yang dibuat oleh Filament. Saya buka folder app/Providers/Filament dan akan menemukan file AdminPanelProvider.php. File ini adalah pusat konfigurasi untuk panel admin yang baru saya buat.
Saya buka file tersebut dan lihat isinya:
<?php
namespace App\\Providers\\Filament;
use Filament\\Http\\Middleware\\Authenticate;
use Filament\\Http\\Middleware\\DisableBladeIconComponents;
use Filament\\Http\\Middleware\\DispatchServingFilamentEvent;
use Filament\\Pages;
use Filament\\Panel;
use Filament\\PanelProvider;
use Filament\\Support\\Colors\\Color;
use Filament\\Widgets;
use Illuminate\\Cookie\\Middleware\\AddQueuedCookiesToResponse;
use Illuminate\\Cookie\\Middleware\\EncryptCookies;
use Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken;
use Illuminate\\Routing\\Middleware\\SubstituteBindings;
use Illuminate\\Session\\Middleware\\AuthenticateSession;
use Illuminate\\Session\\Middleware\\StartSession;
use Illuminate\\View\\Middleware\\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->colors([
'primary' => Color::Amber,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\\\Filament\\\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\\\Filament\\\\Pages')
->pages([
Pages\\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\\\Filament\\\\Widgets')
->widgets([
Widgets\\AccountWidget::class,
Widgets\\FilamentInfoWidget::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}
Mengkustomisasi Panel Admin
Sekarang saya akan mengkustomisasi tampilan panel admin agar sesuai dengan branding projek booking lapangan tenis dan padel. Saya modifikasi file AdminPanelProvider.php menjadi seperti ini:
<?php
namespace App\\Providers\\Filament;
use Filament\\Http\\Middleware\\Authenticate;
use Filament\\Http\\Middleware\\DisableBladeIconComponents;
use Filament\\Http\\Middleware\\DispatchServingFilamentEvent;
use Filament\\Pages;
use Filament\\Panel;
use Filament\\PanelProvider;
use Filament\\Support\\Colors\\Color;
use Filament\\Widgets;
use Illuminate\\Cookie\\Middleware\\AddQueuedCookiesToResponse;
use Illuminate\\Cookie\\Middleware\\EncryptCookies;
use Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken;
use Illuminate\\Routing\\Middleware\\SubstituteBindings;
use Illuminate\\Session\\Middleware\\AuthenticateSession;
use Illuminate\\Session\\Middleware\\StartSession;
use Illuminate\\View\\Middleware\\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->brandName('Court Booking Admin')
->brandLogo(asset('images/logo.png'))
->brandLogoHeight('3rem')
->favicon(asset('images/favicon.ico'))
->colors([
'primary' => Color::Emerald,
'danger' => Color::Red,
'warning' => Color::Amber,
'success' => Color::Green,
'info' => Color::Blue,
])
->font('Poppins')
->sidebarCollapsibleOnDesktop()
->navigationGroups([
'Booking Management',
'Master Data',
'Reports',
'Settings',
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\\\Filament\\\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\\\Filament\\\\Pages')
->pages([
Pages\\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\\\Filament\\\\Widgets')
->widgets([
Widgets\\AccountWidget::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}
Saya menjelaskan beberapa kustomisasi yang saya lakukan di atas:
Saya menambahkan brandName('Court Booking Admin') untuk menampilkan nama aplikasi di sidebar. Saya juga menambahkan brandLogo() dan favicon() untuk branding yang lebih profesional. Untuk logo, nanti saya akan menaruh file gambar di folder public/images.
Saya mengubah warna primary menjadi Color::Emerald karena warna hijau sangat cocok untuk aplikasi yang berhubungan dengan olahraga dan lapangan. Saya juga menambahkan warna-warna lain untuk danger, warning, success, dan info agar konsisten.
Saya menggunakan font('Poppins') untuk memberikan tampilan yang lebih modern. Font Poppins akan di-load otomatis dari Google Fonts.
Saya menambahkan sidebarCollapsibleOnDesktop() agar sidebar bisa di-collapse untuk memberikan lebih banyak ruang untuk konten.
Saya juga mendefinisikan navigationGroups() untuk mengelompokkan menu-menu di sidebar nanti. Ini akan membuat navigasi lebih terorganisir.
Membuat Akun Admin Pertama
Sekarang saya perlu membuat akun admin pertama untuk bisa login ke panel admin. Filament menyediakan perintah Artisan yang sangat memudahkan proses ini:
php artisan make:filament-user
Setelah menjalankan perintah ini, saya akan diminta untuk mengisi beberapa informasi:
Name:
> Admin Lapangan
Email:
> [email protected]
Password:
> ********
Success! [email protected] may now log in at <http://localhost:8000/admin/login>
Saya mengisi nama, email, dan password untuk akun admin. Password yang saya masukkan akan otomatis di-hash oleh Laravel, jadi aman untuk disimpan di database.
Mengakses Halaman Admin
Sekarang saya coba akses halaman admin yang sudah dibuat. Pertama, saya pastikan development server Laravel sudah berjalan:
php artisan serve
Kemudian saya buka browser dan akses URL http://localhost:8000/admin. Saya akan melihat halaman login Filament yang sangat clean dan profesional.
Saya masukkan email [email protected] dan password yang sudah saya buat tadi, kemudian klik tombol Sign in. Kalau berhasil, saya akan masuk ke dashboard admin.
Membuat Custom Dashboard
Dashboard default Filament masih kosong, jadi saya akan membuat beberapa widget untuk menampilkan informasi penting di dashboard. Saya mulai dengan membuat widget untuk statistik booking.
Pertama, saya bikin file widget baru:
php artisan make:filament-widget StatsOverview --stats-overview
Kemudian saya buka file app/Filament/Widgets/StatsOverview.php dan modifikasi isinya:
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Booking;
use App\\Models\\Court;
use App\\Models\\Member;
use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;
class StatsOverview extends BaseWidget
{
protected static ?int $sort = 1;
protected function getStats(): array
{
$todayBookings = Booking::whereDate('booking_date', today())->count();
$pendingPayments = Booking::where('payment_status', 'pending')->count();
$totalMembers = Member::where('is_active', true)->count();
$availableCourts = Court::where('is_available', true)->count();
$todayRevenue = Booking::whereDate('booking_date', today())
->where('payment_status', 'paid')
->sum('total_price');
$monthlyRevenue = Booking::whereMonth('booking_date', now()->month)
->whereYear('booking_date', now()->year)
->where('payment_status', 'paid')
->sum('total_price');
return [
Stat::make('Booking Hari Ini', $todayBookings)
->description('Total reservasi hari ini')
->descriptionIcon('heroicon-m-calendar')
->color('success')
->chart([7, 3, 4, 5, 6, 3, 5]),
Stat::make('Menunggu Pembayaran', $pendingPayments)
->description('Booking belum dibayar')
->descriptionIcon('heroicon-m-clock')
->color($pendingPayments > 5 ? 'danger' : 'warning'),
Stat::make('Total Member Aktif', $totalMembers)
->description('Member terdaftar')
->descriptionIcon('heroicon-m-users')
->color('info'),
Stat::make('Lapangan Tersedia', $availableCourts)
->description('Siap digunakan')
->descriptionIcon('heroicon-m-check-circle')
->color('success'),
Stat::make('Pendapatan Hari Ini', 'Rp ' . number_format($todayRevenue, 0, ',', '.'))
->description('Revenue hari ini')
->descriptionIcon('heroicon-m-banknotes')
->color('success'),
Stat::make('Pendapatan Bulan Ini', 'Rp ' . number_format($monthlyRevenue, 0, ',', '.'))
->description('Revenue bulan ' . now()->format('F'))
->descriptionIcon('heroicon-m-chart-bar')
->color('primary'),
];
}
}
Widget ini akan menampilkan 6 kartu statistik di dashboard. Saya menampilkan jumlah booking hari ini, pembayaran pending, total member aktif, lapangan tersedia, pendapatan hari ini, dan pendapatan bulan ini. Setiap kartu saya beri warna dan icon yang sesuai agar mudah dibaca.
Membuat Widget Chart untuk Booking
Selanjutnya saya bikin widget chart untuk menampilkan grafik booking dalam seminggu terakhir:
php artisan make:filament-widget BookingChart --chart
Saya buka file app/Filament/Widgets/BookingChart.php dan modifikasi:
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Booking;
use Carbon\\Carbon;
use Filament\\Widgets\\ChartWidget;
class BookingChart extends ChartWidget
{
protected static ?string $heading = 'Booking 7 Hari Terakhir';
protected static ?int $sort = 2;
protected static string $color = 'success';
protected function getData(): array
{
$data = [];
$labels = [];
for ($i = 6; $i >= 0; $i--) {
$date = Carbon::now()->subDays($i);
$labels[] = $date->format('D, d M');
$data[] = Booking::whereDate('booking_date', $date)
->whereNotIn('booking_status', ['cancelled'])
->count();
}
return [
'datasets' => [
[
'label' => 'Jumlah Booking',
'data' => $data,
'backgroundColor' => 'rgba(16, 185, 129, 0.5)',
'borderColor' => 'rgb(16, 185, 129)',
],
],
'labels' => $labels,
];
}
protected function getType(): string
{
return 'bar';
}
}
Chart ini akan menampilkan grafik batang yang menunjukkan jumlah booking per hari dalam 7 hari terakhir. Ini sangat berguna untuk melihat tren booking secara visual.
Membuat Widget untuk Booking Hari Ini
Saya juga bikin widget tabel untuk menampilkan daftar booking hari ini:
php artisan make:filament-widget TodayBookings
Saya buka file app/Filament/Widgets/TodayBookings.php dan modifikasi:
<?php
namespace App\\Filament\\Widgets;
use App\\Models\\Booking;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Widgets\\TableWidget as BaseWidget;
class TodayBookings extends BaseWidget
{
protected static ?string $heading = 'Booking Hari Ini';
protected static ?int $sort = 3;
protected int|string|array $columnSpan = 'full';
public function table(Table $table): Table
{
return $table
->query(
Booking::query()
->whereDate('booking_date', today())
->with(['court', 'member', 'timeSlot'])
->orderBy('time_slot_id')
)
->columns([
Tables\\Columns\\TextColumn::make('booking_code')
->label('Kode Booking')
->searchable()
->copyable()
->weight('bold'),
Tables\\Columns\\TextColumn::make('member.name')
->label('Member')
->searchable(),
Tables\\Columns\\TextColumn::make('court.name')
->label('Lapangan')
->badge()
->color('info'),
Tables\\Columns\\TextColumn::make('timeSlot.formatted_slot')
->label('Waktu'),
Tables\\Columns\\TextColumn::make('total_price')
->label('Total')
->money('IDR')
->sortable(),
Tables\\Columns\\TextColumn::make('payment_status')
->label('Pembayaran')
->badge()
->color(fn (string $state): string => match ($state) {
'paid' => 'success',
'pending' => 'warning',
'failed' => 'danger',
'refunded' => 'info',
default => 'gray',
}),
Tables\\Columns\\TextColumn::make('booking_status')
->label('Status')
->badge()
->color(fn (string $state): string => match ($state) {
'confirmed' => 'success',
'pending' => 'warning',
'completed' => 'info',
'cancelled' => 'danger',
'no_show' => 'gray',
default => 'gray',
}),
])
->defaultPaginationPageOption(5)
->emptyStateHeading('Belum ada booking hari ini')
->emptyStateDescription('Booking akan muncul di sini setelah ada reservasi baru.')
->emptyStateIcon('heroicon-o-calendar');
}
}
Widget ini menampilkan tabel dengan daftar semua booking untuk hari ini. Staff operasional bisa dengan cepat melihat siapa saja yang akan datang hari ini, di lapangan mana, dan jam berapa. Status pembayaran dan booking juga ditampilkan dengan warna badge yang berbeda agar mudah dibedakan.
Mengatur Urutan Widget di Dashboard
Saya sudah menambahkan properti $sort di setiap widget untuk mengatur urutannya di dashboard. Widget dengan sort lebih kecil akan muncul lebih dulu. Jadi urutannya adalah:
- StatsOverview (sort = 1) - Muncul paling atas
- BookingChart (sort = 2) - Di bawah stats
- TodayBookings (sort = 3) - Paling bawah
Menjalankan Optimize untuk Mempercepat Loading
Setelah semua konfigurasi selesai, saya jalankan perintah optimize untuk mempercepat loading aplikasi:
php artisan optimize:clear
php artisan filament:optimize
Perintah ini akan membersihkan cache dan mengoptimasi Filament agar berjalan lebih cepat.
Mengecek Hasil di Browser
Sekarang saya refresh halaman admin di browser. Saya akan melihat dashboard yang sudah terisi dengan widget-widget yang baru saya buat. Ada kartu statistik di bagian atas, grafik booking di tengah, dan tabel booking hari ini di bagian bawah.
Tentu saja, karena belum ada data, semua angka masih nol dan tabelnya masih kosong. Tapi struktur dashboard sudah siap dan akan terisi otomatis begitu ada data booking masuk.
Sampai di sini saya sudah berhasil menginstall Filament, membuat akun admin, mengkustomisasi tampilan panel admin, dan membuat beberapa widget untuk dashboard. Di bagian selanjutnya, saya akan membuat Resource untuk CRUD semua tabel yang sudah dibuat sebelumnya.
Bagian 5: Membuat Resource untuk CRUD Seluruh Tabel
Sekarang saya masuk ke bagian yang paling seru, yaitu membuat Resource untuk mengelola data melalui halaman admin. Resource di Filament adalah komponen yang menangani operasi CRUD (Create, Read, Update, Delete) untuk setiap model. Saya akan membuat resource untuk semua tabel yang sudah saya buat sebelumnya: CourtType, Court, TimeSlot, Member, dan Booking.
Membuat CourtTypeResource
Saya mulai dengan membuat resource untuk tabel court_types karena ini adalah tabel master yang paling sederhana. Saya jalankan perintah Artisan berikut:
php artisan make:filament-resource CourtType --generate
Flag --generate akan membuat Filament secara otomatis men-generate form dan table berdasarkan kolom-kolom yang ada di migration. Tapi saya akan memodifikasinya agar lebih sesuai dengan kebutuhan.
Setelah perintah dijalankan, Filament akan membuat beberapa file di folder app/Filament/Resources/CourtTypeResource. Saya buka file utamanya yaitu CourtTypeResource.php dan modifikasi seperti ini:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\CourtTypeResource\\Pages;
use App\\Models\\CourtType;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class CourtTypeResource extends Resource
{
protected static ?string $model = CourtType::class;
protected static ?string $navigationIcon = 'heroicon-o-tag';
protected static ?string $navigationLabel = 'Tipe Lapangan';
protected static ?string $modelLabel = 'Tipe Lapangan';
protected static ?string $pluralModelLabel = 'Tipe Lapangan';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Tipe Lapangan')
->description('Masukkan detail tipe lapangan yang tersedia')
->schema([
Forms\\Components\\TextInput::make('name')
->label('Nama Tipe')
->required()
->maxLength(255)
->placeholder('Contoh: Tenis Indoor, Padel, dll')
->columnSpanFull(),
Forms\\Components\\Textarea::make('description')
->label('Deskripsi')
->rows(3)
->placeholder('Jelaskan tentang tipe lapangan ini...')
->columnSpanFull(),
Forms\\Components\\TextInput::make('icon')
->label('Icon/Emoji')
->maxLength(50)
->placeholder('🎾 atau nama heroicon')
->helperText('Masukkan emoji atau nama icon dari Heroicons'),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true)
->helperText('Nonaktifkan jika tipe lapangan ini tidak tersedia sementara'),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('icon')
->label('')
->searchable(),
Tables\\Columns\\TextColumn::make('name')
->label('Nama Tipe')
->searchable()
->sortable()
->weight('bold'),
Tables\\Columns\\TextColumn::make('description')
->label('Deskripsi')
->limit(50)
->tooltip(function (Tables\\Columns\\TextColumn $column): ?string {
$state = $column->getState();
if (strlen($state) <= 50) {
return null;
}
return $state;
}),
Tables\\Columns\\TextColumn::make('courts_count')
->label('Jumlah Lapangan')
->counts('courts')
->badge()
->color('info'),
Tables\\Columns\\IconColumn::make('is_active')
->label('Status')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger'),
Tables\\Columns\\TextColumn::make('created_at')
->label('Dibuat')
->dateTime('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\\Filters\\TernaryFilter::make('is_active')
->label('Status Aktif')
->boolean()
->trueLabel('Aktif')
->falseLabel('Nonaktif')
->placeholder('Semua'),
])
->actions([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
])
->emptyStateHeading('Belum ada tipe lapangan')
->emptyStateDescription('Mulai dengan menambahkan tipe lapangan pertama.')
->emptyStateIcon('heroicon-o-tag');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListCourtTypes::route('/'),
'create' => Pages\\CreateCourtType::route('/create'),
'edit' => Pages\\EditCourtType::route('/{record}/edit'),
];
}
}
Saya jelaskan beberapa hal penting yang saya atur di resource ini. Pertama, saya menambahkan $navigationIcon untuk icon di sidebar, $navigationLabel untuk label menu, dan $navigationGroup untuk mengelompokkan menu ke dalam grup "Master Data".
Di bagian form, saya menggunakan Section untuk mengelompokkan field-field yang berhubungan. Saya juga menambahkan placeholder dan helperText untuk membantu admin memahami apa yang harus diisi.
Di bagian table, saya menambahkan kolom courts_count yang menggunakan method counts('courts') untuk menampilkan jumlah lapangan yang terkait dengan tipe ini. Saya juga menambahkan filter untuk memfilter berdasarkan status aktif.
Membuat CourtResource
Selanjutnya saya bikin resource untuk tabel courts:
php artisan make:filament-resource Court --generate
Saya buka file CourtResource.php dan modifikasi:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\CourtResource\\Pages;
use App\\Models\\Court;
use App\\Models\\CourtType;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class CourtResource extends Resource
{
protected static ?string $model = Court::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office';
protected static ?string $navigationLabel = 'Lapangan';
protected static ?string $modelLabel = 'Lapangan';
protected static ?string $pluralModelLabel = 'Lapangan';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Dasar')
->schema([
Forms\\Components\\Select::make('court_type_id')
->label('Tipe Lapangan')
->relationship('courtType', 'name')
->required()
->searchable()
->preload()
->createOptionForm([
Forms\\Components\\TextInput::make('name')
->label('Nama Tipe')
->required(),
Forms\\Components\\Textarea::make('description')
->label('Deskripsi'),
]),
Forms\\Components\\TextInput::make('name')
->label('Nama Lapangan')
->required()
->maxLength(255)
->placeholder('Contoh: Lapangan A, Court 1, dll'),
Forms\\Components\\Textarea::make('description')
->label('Deskripsi')
->rows(3)
->placeholder('Deskripsi singkat tentang lapangan ini...')
->columnSpanFull(),
Forms\\Components\\FileUpload::make('image')
->label('Foto Lapangan')
->image()
->directory('courts')
->imageEditor()
->imageEditorAspectRatios([
'16:9',
'4:3',
'1:1',
])
->columnSpanFull(),
])->columns(2),
Forms\\Components\\Section::make('Harga dan Kapasitas')
->schema([
Forms\\Components\\TextInput::make('price_per_hour')
->label('Harga per Jam (Weekday)')
->required()
->numeric()
->prefix('Rp')
->placeholder('150000'),
Forms\\Components\\TextInput::make('weekend_price_per_hour')
->label('Harga per Jam (Weekend)')
->numeric()
->prefix('Rp')
->placeholder('200000')
->helperText('Kosongkan jika sama dengan harga weekday'),
Forms\\Components\\TextInput::make('capacity')
->label('Kapasitas Pemain')
->numeric()
->default(4)
->suffix('orang')
->minValue(1)
->maxValue(20),
Forms\\Components\\Toggle::make('is_available')
->label('Tersedia untuk Booking')
->default(true)
->helperText('Nonaktifkan jika lapangan sedang maintenance'),
])->columns(2),
Forms\\Components\\Section::make('Fasilitas')
->schema([
Forms\\Components\\CheckboxList::make('facilities')
->label('Fasilitas yang Tersedia')
->options([
'lighting' => '💡 Pencahayaan',
'ac' => '❄️ Air Conditioner',
'wifi' => '📶 WiFi',
'locker' => '🔐 Locker Room',
'shower' => '🚿 Shower',
'parking' => '🅿️ Parkir',
'toilet' => '🚻 Toilet',
'waiting_room' => '🪑 Ruang Tunggu',
'water_dispenser' => '💧 Dispenser Air',
'sound_system' => '🔊 Sound System',
])
->columns(3)
->gridDirection('row'),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\ImageColumn::make('image')
->label('Foto')
->circular()
->defaultImageUrl(fn () => asset('images/default-court.png')),
Tables\\Columns\\TextColumn::make('name')
->label('Nama Lapangan')
->searchable()
->sortable()
->weight('bold'),
Tables\\Columns\\TextColumn::make('courtType.name')
->label('Tipe')
->badge()
->color('info')
->searchable(),
Tables\\Columns\\TextColumn::make('price_per_hour')
->label('Harga/Jam')
->money('IDR')
->sortable(),
Tables\\Columns\\TextColumn::make('weekend_price_per_hour')
->label('Harga Weekend')
->money('IDR')
->placeholder('-')
->toggleable(isToggledHiddenByDefault: true),
Tables\\Columns\\TextColumn::make('capacity')
->label('Kapasitas')
->suffix(' orang')
->sortable(),
Tables\\Columns\\TextColumn::make('facilities')
->label('Fasilitas')
->badge()
->separator(',')
->limitList(3)
->expandableLimitedList(),
Tables\\Columns\\IconColumn::make('is_available')
->label('Tersedia')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger'),
Tables\\Columns\\TextColumn::make('bookings_count')
->label('Total Booking')
->counts('bookings')
->badge()
->color('success')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\\Filters\\SelectFilter::make('court_type_id')
->label('Tipe Lapangan')
->relationship('courtType', 'name')
->preload(),
Tables\\Filters\\TernaryFilter::make('is_available')
->label('Ketersediaan')
->boolean()
->trueLabel('Tersedia')
->falseLabel('Tidak Tersedia')
->placeholder('Semua'),
])
->actions([
Tables\\Actions\\ActionGroup::make([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
]),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
Tables\\Actions\\BulkAction::make('markAvailable')
->label('Tandai Tersedia')
->icon('heroicon-o-check-circle')
->color('success')
->action(fn ($records) => $records->each->update(['is_available' => true]))
->requiresConfirmation(),
Tables\\Actions\\BulkAction::make('markUnavailable')
->label('Tandai Tidak Tersedia')
->icon('heroicon-o-x-circle')
->color('danger')
->action(fn ($records) => $records->each->update(['is_available' => false]))
->requiresConfirmation(),
]),
])
->emptyStateHeading('Belum ada lapangan')
->emptyStateDescription('Mulai dengan menambahkan lapangan pertama.')
->emptyStateIcon('heroicon-o-building-office');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListCourts::route('/'),
'create' => Pages\\CreateCourt::route('/create'),
'edit' => Pages\\EditCourt::route('/{record}/edit'),
];
}
}
Di resource Court ini, saya menambahkan beberapa fitur menarik. Untuk field tipe lapangan, saya menggunakan Select dengan relationship() yang memungkinkan admin memilih dari tipe yang sudah ada, dan saya juga menambahkan createOptionForm() agar admin bisa langsung membuat tipe baru tanpa harus pindah halaman.
Saya menggunakan FileUpload untuk upload foto lapangan dengan fitur imageEditor() yang memungkinkan admin untuk crop dan resize gambar langsung dari browser.
Untuk fasilitas, saya menggunakan CheckboxList dengan pilihan-pilihan yang sudah saya definisikan. Data ini akan disimpan sebagai JSON di database dan otomatis dikonversi menjadi array oleh Eloquent.
Di bagian bulk actions, saya menambahkan action custom untuk menandai beberapa lapangan sebagai tersedia atau tidak tersedia sekaligus. Ini sangat berguna ketika ada maintenance massal.
Membuat TimeSlotResource
Sekarang saya bikin resource untuk tabel time_slots:
php artisan make:filament-resource TimeSlot --generate
Saya buka file TimeSlotResource.php dan modifikasi:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\TimeSlotResource\\Pages;
use App\\Models\\TimeSlot;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class TimeSlotResource extends Resource
{
protected static ?string $model = TimeSlot::class;
protected static ?string $navigationIcon = 'heroicon-o-clock';
protected static ?string $navigationLabel = 'Slot Waktu';
protected static ?string $modelLabel = 'Slot Waktu';
protected static ?string $pluralModelLabel = 'Slot Waktu';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 3;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Pengaturan Waktu')
->schema([
Forms\\Components\\TimePicker::make('start_time')
->label('Jam Mulai')
->required()
->seconds(false)
->displayFormat('H:i')
->native(false),
Forms\\Components\\TimePicker::make('end_time')
->label('Jam Selesai')
->required()
->seconds(false)
->displayFormat('H:i')
->native(false)
->after('start_time'),
Forms\\Components\\TextInput::make('label')
->label('Label (Opsional)')
->maxLength(50)
->placeholder('Contoh: Pagi, Siang, Sore, Malam')
->helperText('Label tambahan untuk memudahkan identifikasi'),
Forms\\Components\\Toggle::make('is_active')
->label('Aktif')
->default(true),
])->columns(2),
Forms\\Components\\Section::make('Pengaturan Prime Time')
->description('Prime time adalah jam-jam sibuk dengan harga tambahan')
->schema([
Forms\\Components\\Toggle::make('is_prime_time')
->label('Ini adalah Prime Time')
->reactive()
->helperText('Aktifkan jika slot ini termasuk jam sibuk'),
Forms\\Components\\TextInput::make('prime_time_surcharge')
->label('Biaya Tambahan Prime Time')
->numeric()
->prefix('Rp')
->default(0)
->placeholder('50000')
->visible(fn (Forms\\Get $get) => $get('is_prime_time'))
->helperText('Biaya tambahan yang dikenakan saat prime time'),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('label')
->label('Label')
->badge()
->color('gray')
->placeholder('-'),
Tables\\Columns\\TextColumn::make('start_time')
->label('Jam Mulai')
->time('H:i')
->sortable(),
Tables\\Columns\\TextColumn::make('end_time')
->label('Jam Selesai')
->time('H:i')
->sortable(),
Tables\\Columns\\TextColumn::make('formatted_slot')
->label('Slot Waktu')
->badge()
->color('info')
->weight('bold'),
Tables\\Columns\\IconColumn::make('is_prime_time')
->label('Prime Time')
->boolean()
->trueIcon('heroicon-o-fire')
->falseIcon('heroicon-o-minus')
->trueColor('warning')
->falseColor('gray'),
Tables\\Columns\\TextColumn::make('prime_time_surcharge')
->label('Biaya Tambahan')
->money('IDR')
->placeholder('-')
->color(fn ($state) => $state > 0 ? 'warning' : 'gray'),
Tables\\Columns\\IconColumn::make('is_active')
->label('Status')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger'),
Tables\\Columns\\TextColumn::make('bookings_count')
->label('Digunakan')
->counts('bookings')
->suffix('x')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('start_time', 'asc')
->filters([
Tables\\Filters\\TernaryFilter::make('is_prime_time')
->label('Prime Time')
->boolean()
->trueLabel('Prime Time')
->falseLabel('Regular')
->placeholder('Semua'),
Tables\\Filters\\TernaryFilter::make('is_active')
->label('Status')
->boolean()
->trueLabel('Aktif')
->falseLabel('Nonaktif')
->placeholder('Semua'),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make(),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
Tables\\Actions\\BulkAction::make('setPrimeTime')
->label('Set Prime Time')
->icon('heroicon-o-fire')
->color('warning')
->form([
Forms\\Components\\TextInput::make('surcharge')
->label('Biaya Tambahan')
->numeric()
->prefix('Rp')
->required(),
])
->action(function ($records, array $data) {
$records->each->update([
'is_prime_time' => true,
'prime_time_surcharge' => $data['surcharge'],
]);
})
->requiresConfirmation(),
]),
])
->emptyStateHeading('Belum ada slot waktu')
->emptyStateDescription('Buat slot waktu untuk jadwal booking.')
->emptyStateIcon('heroicon-o-clock');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListTimeSlots::route('/'),
'create' => Pages\\CreateTimeSlot::route('/create'),
'edit' => Pages\\EditTimeSlot::route('/{record}/edit'),
];
}
}
Di resource TimeSlot ini, saya menggunakan TimePicker untuk input jam mulai dan jam selesai. Saya set seconds(false) karena kita tidak perlu presisi sampai detik untuk slot booking.
Yang menarik adalah saya menggunakan reactive() pada toggle is_prime_time dan visible() pada field prime_time_surcharge. Dengan cara ini, field biaya tambahan hanya akan muncul ketika toggle prime time diaktifkan. Ini membuat form lebih clean dan tidak membingungkan admin.
Di bulk actions, saya menambahkan action untuk mengset beberapa slot sebagai prime time sekaligus dengan form untuk memasukkan biaya tambahan.
Membuat MemberResource
Sekarang saya bikin resource untuk tabel members:
php artisan make:filament-resource Member --generate
Saya buka file MemberResource.php dan modifikasi:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\MemberResource\\Pages;
use App\\Models\\Member;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
class MemberResource extends Resource
{
protected static ?string $model = Member::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationLabel = 'Member';
protected static ?string $modelLabel = 'Member';
protected static ?string $pluralModelLabel = 'Member';
protected static ?string $navigationGroup = 'Booking Management';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Pribadi')
->schema([
Forms\\Components\\FileUpload::make('profile_photo')
->label('Foto Profil')
->image()
->avatar()
->directory('members')
->imageEditor()
->circleCropper()
->columnSpanFull(),
Forms\\Components\\TextInput::make('name')
->label('Nama Lengkap')
->required()
->maxLength(255),
Forms\\Components\\TextInput::make('email')
->label('Email')
->email()
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Forms\\Components\\TextInput::make('phone')
->label('No. Telepon')
->tel()
->required()
->maxLength(20)
->placeholder('08xxxxxxxxxx'),
Forms\\Components\\DatePicker::make('date_of_birth')
->label('Tanggal Lahir')
->native(false)
->displayFormat('d F Y')
->maxDate(now()->subYears(10)),
Forms\\Components\\Select::make('gender')
->label('Jenis Kelamin')
->options([
'male' => 'Laki-laki',
'female' => 'Perempuan',
])
->native(false),
Forms\\Components\\Textarea::make('address')
->label('Alamat')
->rows(3)
->columnSpanFull(),
])->columns(2),
Forms\\Components\\Section::make('Membership')
->schema([
Forms\\Components\\Select::make('membership_type')
->label('Tipe Membership')
->options([
'regular' => '⚪ Regular (Tanpa Diskon)',
'silver' => '🥈 Silver (Diskon 10%)',
'gold' => '🥇 Gold (Diskon 15%)',
'platinum' => '💎 Platinum (Diskon 20%)',
])
->default('regular')
->native(false)
->required(),
Forms\\Components\\DatePicker::make('membership_expired_at')
->label('Masa Berlaku Sampai')
->native(false)
->displayFormat('d F Y')
->minDate(now())
->helperText('Kosongkan untuk member regular'),
Forms\\Components\\Toggle::make('is_active')
->label('Member Aktif')
->default(true),
])->columns(3),
Forms\\Components\\Section::make('Statistik')
->schema([
Forms\\Components\\Placeholder::make('total_bookings_display')
->label('Total Booking')
->content(fn (?Member $record): string => $record ? $record->total_bookings . ' kali' : '0 kali'),
Forms\\Components\\Placeholder::make('total_spent_display')
->label('Total Pengeluaran')
->content(fn (?Member $record): string => $record ? 'Rp ' . number_format($record->total_spent, 0, ',', '.') : 'Rp 0'),
Forms\\Components\\Placeholder::make('member_since')
->label('Member Sejak')
->content(fn (?Member $record): string => $record ? $record->created_at->format('d F Y') : '-'),
])
->columns(3)
->hidden(fn (?Member $record): bool => $record === null),
Forms\\Components\\Section::make('Akun Login')
->schema([
Forms\\Components\\Select::make('user_id')
->label('Hubungkan dengan Akun User')
->relationship('user', 'email')
->searchable()
->preload()
->helperText('Opsional. Hubungkan member dengan akun user untuk login ke sistem.'),
])
->collapsed(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\ImageColumn::make('profile_photo')
->label('')
->circular()
->defaultImageUrl(fn ($record) => '<https://ui-avatars.com/api/?name=>' . urlencode($record->name) . '&color=7F9CF5&background=EBF4FF'),
Tables\\Columns\\TextColumn::make('name')
->label('Nama')
->searchable()
->sortable()
->weight('bold'),
Tables\\Columns\\TextColumn::make('email')
->label('Email')
->searchable()
->copyable()
->icon('heroicon-o-envelope'),
Tables\\Columns\\TextColumn::make('phone')
->label('Telepon')
->searchable()
->copyable()
->icon('heroicon-o-phone'),
Tables\\Columns\\TextColumn::make('membership_type')
->label('Membership')
->badge()
->formatStateUsing(fn (string $state): string => match ($state) {
'regular' => 'Regular',
'silver' => 'Silver',
'gold' => 'Gold',
'platinum' => 'Platinum',
default => $state,
})
->color(fn (string $state): string => match ($state) {
'regular' => 'gray',
'silver' => 'info',
'gold' => 'warning',
'platinum' => 'success',
default => 'gray',
}),
Tables\\Columns\\TextColumn::make('membership_expired_at')
->label('Expired')
->date('d M Y')
->placeholder('∞')
->color(fn (?Member $record): string =>
$record && $record->membership_expired_at && $record->membership_expired_at->isPast()
? 'danger'
: 'success'
)
->toggleable(isToggledHiddenByDefault: true),
Tables\\Columns\\TextColumn::make('total_bookings')
->label('Booking')
->suffix('x')
->sortable()
->color('info'),
Tables\\Columns\\TextColumn::make('total_spent')
->label('Total Spent')
->money('IDR')
->sortable()
->color('success'),
Tables\\Columns\\IconColumn::make('is_active')
->label('Aktif')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle'),
Tables\\Columns\\TextColumn::make('created_at')
->label('Terdaftar')
->date('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\\Filters\\SelectFilter::make('membership_type')
->label('Tipe Membership')
->options([
'regular' => 'Regular',
'silver' => 'Silver',
'gold' => 'Gold',
'platinum' => 'Platinum',
]),
Tables\\Filters\\TernaryFilter::make('is_active')
->label('Status')
->boolean()
->trueLabel('Aktif')
->falseLabel('Nonaktif'),
Tables\\Filters\\Filter::make('expired_membership')
->label('Membership Expired')
->query(fn (Builder $query): Builder => $query->where('membership_expired_at', '<', now())),
])
->actions([
Tables\\Actions\\ActionGroup::make([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\Action::make('upgradeMembership')
->label('Upgrade Membership')
->icon('heroicon-o-arrow-up-circle')
->color('success')
->form([
Forms\\Components\\Select::make('membership_type')
->label('Tipe Membership Baru')
->options([
'silver' => 'Silver (Diskon 10%)',
'gold' => 'Gold (Diskon 15%)',
'platinum' => 'Platinum (Diskon 20%)',
])
->required(),
Forms\\Components\\DatePicker::make('expired_at')
->label('Berlaku Sampai')
->required()
->minDate(now()),
])
->action(function (Member $record, array $data) {
$record->update([
'membership_type' => $data['membership_type'],
'membership_expired_at' => $data['expired_at'],
]);
}),
Tables\\Actions\\DeleteAction::make(),
]),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
])
->emptyStateHeading('Belum ada member')
->emptyStateDescription('Member akan muncul setelah ada pendaftaran.')
->emptyStateIcon('heroicon-o-users');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListMembers::route('/'),
'create' => Pages\\CreateMember::route('/create'),
'view' => Pages\\ViewMember::route('/{record}'),
'edit' => Pages\\EditMember::route('/{record}/edit'),
];
}
}
Di resource Member ini, saya menambahkan beberapa fitur khusus. Untuk foto profil, saya menggunakan circleCropper() agar foto otomatis di-crop menjadi lingkaran.
Saya juga menambahkan section Statistik dengan Placeholder yang menampilkan data read-only seperti total booking dan total pengeluaran. Section ini hanya ditampilkan saat edit (bukan create) dengan menggunakan hidden().
Di bagian tabel, saya menggunakan UI Avatars sebagai default image untuk member yang belum upload foto. Ini memberikan tampilan yang lebih profesional dibanding menampilkan placeholder kosong.
Saya juga menambahkan action custom upgradeMembership yang memungkinkan admin untuk mengupgrade membership member langsung dari tabel tanpa harus masuk ke halaman edit.
Membuat BookingResource
Terakhir dan yang paling kompleks, saya bikin resource untuk tabel bookings:
php artisan make:filament-resource Booking --generate
Saya buka file BookingResource.php dan modifikasi:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\BookingResource\\Pages;
use App\\Models\\Booking;
use App\\Models\\Court;
use App\\Models\\TimeSlot;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
class BookingResource extends Resource
{
protected static ?string $model = Booking::class;
protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
protected static ?string $navigationLabel = 'Booking';
protected static ?string $modelLabel = 'Booking';
protected static ?string $pluralModelLabel = 'Booking';
protected static ?string $navigationGroup = 'Booking Management';
protected static ?int $navigationSort = 1;
public static function getNavigationBadge(): ?string
{
return static::getModel()::where('payment_status', 'pending')->count() ?: null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Booking')
->schema([
Forms\\Components\\TextInput::make('booking_code')
->label('Kode Booking')
->disabled()
->dehydrated(false)
->placeholder('Otomatis di-generate')
->helperText('Kode akan di-generate otomatis saat booking dibuat'),
Forms\\Components\\Select::make('member_id')
->label('Member')
->relationship('member', 'name')
->searchable()
->preload()
->required()
->createOptionForm([
Forms\\Components\\TextInput::make('name')
->label('Nama')
->required(),
Forms\\Components\\TextInput::make('email')
->label('Email')
->email()
->required(),
Forms\\Components\\TextInput::make('phone')
->label('Telepon')
->required(),
]),
Forms\\Components\\Select::make('court_id')
->label('Lapangan')
->options(Court::where('is_available', true)->pluck('name', 'id'))
->searchable()
->required()
->reactive()
->afterStateUpdated(fn (Forms\\Set $set) => $set('base_price', null)),
Forms\\Components\\DatePicker::make('booking_date')
->label('Tanggal Booking')
->required()
->native(false)
->displayFormat('l, d F Y')
->minDate(now())
->reactive()
->afterStateUpdated(function (Forms\\Set $set, Forms\\Get $get) {
self::calculatePrice($set, $get);
}),
Forms\\Components\\Select::make('time_slot_id')
->label('Slot Waktu')
->options(TimeSlot::where('is_active', true)->get()->pluck('display_name', 'id'))
->searchable()
->required()
->reactive()
->afterStateUpdated(function (Forms\\Set $set, Forms\\Get $get) {
self::calculatePrice($set, $get);
}),
Forms\\Components\\Textarea::make('notes')
->label('Catatan')
->rows(2)
->placeholder('Catatan tambahan untuk booking ini...')
->columnSpanFull(),
])->columns(2),
Forms\\Components\\Section::make('Kalkulasi Harga')
->schema([
Forms\\Components\\TextInput::make('base_price')
->label('Harga Dasar')
->numeric()
->prefix('Rp')
->disabled()
->dehydrated(true),
Forms\\Components\\TextInput::make('additional_charges')
->label('Biaya Tambahan')
->numeric()
->prefix('Rp')
->default(0)
->reactive()
->afterStateUpdated(function (Forms\\Set $set, Forms\\Get $get) {
self::calculateTotal($set, $get);
}),
Forms\\Components\\TextInput::make('discount')
->label('Diskon')
->numeric()
->prefix('Rp')
->default(0)
->reactive()
->afterStateUpdated(function (Forms\\Set $set, Forms\\Get $get) {
self::calculateTotal($set, $get);
}),
Forms\\Components\\TextInput::make('total_price')
->label('Total Harga')
->numeric()
->prefix('Rp')
->disabled()
->dehydrated(true),
])->columns(4),
Forms\\Components\\Section::make('Status')
->schema([
Forms\\Components\\Select::make('payment_status')
->label('Status Pembayaran')
->options([
'pending' => 'Pending',
'paid' => 'Paid',
'failed' => 'Failed',
'refunded' => 'Refunded',
'expired' => 'Expired',
])
->default('pending')
->required()
->native(false),
Forms\\Components\\Select::make('booking_status')
->label('Status Booking')
->options([
'pending' => 'Pending',
'confirmed' => 'Confirmed',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
'no_show' => 'No Show',
])
->default('pending')
->required()
->native(false),
Forms\\Components\\Select::make('payment_method')
->label('Metode Pembayaran')
->options([
'cash' => 'Cash',
'transfer' => 'Bank Transfer',
'midtrans' => 'Midtrans',
'qris' => 'QRIS',
])
->native(false),
Forms\\Components\\TextInput::make('transaction_id')
->label('ID Transaksi')
->maxLength(255),
])->columns(4),
Forms\\Components\\Section::make('Pembatalan')
->schema([
Forms\\Components\\Textarea::make('cancellation_reason')
->label('Alasan Pembatalan')
->rows(2),
Forms\\Components\\DateTimePicker::make('cancelled_at')
->label('Waktu Pembatalan')
->native(false),
])
->columns(2)
->collapsed()
->visible(fn (Forms\\Get $get) => $get('booking_status') === 'cancelled'),
]);
}
protected static function calculatePrice(Forms\\Set $set, Forms\\Get $get): void
{
$courtId = $get('court_id');
$bookingDate = $get('booking_date');
$timeSlotId = $get('time_slot_id');
if (!$courtId || !$bookingDate) {
return;
}
$court = Court::find($courtId);
$timeSlot = $timeSlotId ? TimeSlot::find($timeSlotId) : null;
if (!$court) {
return;
}
$basePrice = $court->getPriceForDate($bookingDate);
if ($timeSlot && $timeSlot->is_prime_time) {
$basePrice += $timeSlot->prime_time_surcharge;
}
$set('base_price', $basePrice);
self::calculateTotal($set, $get);
}
protected static function calculateTotal(Forms\\Set $set, Forms\\Get $get): void
{
$basePrice = floatval($get('base_price') ?? 0);
$additionalCharges = floatval($get('additional_charges') ?? 0);
$discount = floatval($get('discount') ?? 0);
$total = $basePrice + $additionalCharges - $discount;
$set('total_price', max(0, $total));
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('booking_code')
->label('Kode')
->searchable()
->copyable()
->weight('bold')
->color('primary'),
Tables\\Columns\\TextColumn::make('member.name')
->label('Member')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('court.name')
->label('Lapangan')
->badge()
->color('info'),
Tables\\Columns\\TextColumn::make('booking_date')
->label('Tanggal')
->date('D, d M Y')
->sortable()
->color(fn ($state) => $state < now()->toDateString() ? 'gray' : 'success'),
Tables\\Columns\\TextColumn::make('timeSlot.formatted_slot')
->label('Waktu'),
Tables\\Columns\\TextColumn::make('total_price')
->label('Total')
->money('IDR')
->sortable(),
Tables\\Columns\\TextColumn::make('payment_status')
->label('Pembayaran')
->badge()
->color(fn (string $state): string => match ($state) {
'paid' => 'success',
'pending' => 'warning',
'failed' => 'danger',
'refunded' => 'info',
'expired' => 'gray',
default => 'gray',
}),
Tables\\Columns\\TextColumn::make('booking_status')
->label('Status')
->badge()
->color(fn (string $state): string => match ($state) {
'confirmed' => 'success',
'pending' => 'warning',
'completed' => 'info',
'cancelled' => 'danger',
'no_show' => 'gray',
default => 'gray',
}),
Tables\\Columns\\TextColumn::make('created_at')
->label('Dibuat')
->dateTime('d M Y H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('booking_date', 'desc')
->filters([
Tables\\Filters\\SelectFilter::make('court_id')
->label('Lapangan')
->relationship('court', 'name'),
Tables\\Filters\\SelectFilter::make('payment_status')
->label('Status Pembayaran')
->options([
'pending' => 'Pending',
'paid' => 'Paid',
'failed' => 'Failed',
'refunded' => 'Refunded',
'expired' => 'Expired',
]),
Tables\\Filters\\SelectFilter::make('booking_status')
->label('Status Booking')
->options([
'pending' => 'Pending',
'confirmed' => 'Confirmed',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
'no_show' => 'No Show',
]),
Tables\\Filters\\Filter::make('booking_date')
->form([
Forms\\Components\\DatePicker::make('from')
->label('Dari Tanggal'),
Forms\\Components\\DatePicker::make('until')
->label('Sampai Tanggal'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['from'],
fn (Builder $query, $date): Builder => $query->whereDate('booking_date', '>=', $date),
)
->when(
$data['until'],
fn (Builder $query, $date): Builder => $query->whereDate('booking_date', '<=', $date),
);
}),
Tables\\Filters\\Filter::make('today')
->label('Hari Ini')
->query(fn (Builder $query): Builder => $query->whereDate('booking_date', today()))
->toggle(),
Tables\\Filters\\Filter::make('upcoming')
->label('Upcoming')
->query(fn (Builder $query): Builder => $query->where('booking_date', '>=', today()))
->toggle(),
])
->actions([
Tables\\Actions\\ActionGroup::make([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\Action::make('markAsPaid')
->label('Tandai Lunas')
->icon('heroicon-o-check-circle')
->color('success')
->visible(fn (Booking $record) => $record->payment_status === 'pending')
->form([
Forms\\Components\\Select::make('payment_method')
->label('Metode Pembayaran')
->options([
'cash' => 'Cash',
'transfer' => 'Bank Transfer',
'qris' => 'QRIS',
])
->required(),
])
->action(function (Booking $record, array $data) {
$record->markAsPaid($data['payment_method']);
})
->requiresConfirmation(),
Tables\\Actions\\Action::make('cancel')
->label('Batalkan')
->icon('heroicon-o-x-circle')
->color('danger')
->visible(fn (Booking $record) => !in_array($record->booking_status, ['cancelled', 'completed']))
->form([
Forms\\Components\\Textarea::make('reason')
->label('Alasan Pembatalan')
->required(),
])
->action(function (Booking $record, array $data) {
$record->cancel($data['reason']);
})
->requiresConfirmation(),
Tables\\Actions\\Action::make('complete')
->label('Selesai')
->icon('heroicon-o-flag')
->color('info')
->visible(fn (Booking $record) => $record->booking_status === 'confirmed')
->action(fn (Booking $record) => $record->complete())
->requiresConfirmation(),
]),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
])
->emptyStateHeading('Belum ada booking')
->emptyStateDescription('Booking akan muncul setelah ada reservasi.')
->emptyStateIcon('heroicon-o-calendar-days');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListBookings::route('/'),
'create' => Pages\\CreateBooking::route('/create'),
'view' => Pages\\ViewBooking::route('/{record}'),
'edit' => Pages\\EditBooking::route('/{record}/edit'),
];
}
}
Resource Booking ini adalah yang paling kompleks karena melibatkan banyak logika bisnis. Saya menambahkan getNavigationBadge() untuk menampilkan jumlah booking pending di sidebar, sehingga admin bisa langsung tahu ada berapa booking yang perlu diperhatikan.
Saya membuat helper method calculatePrice() dan calculateTotal() untuk menghitung harga otomatis berdasarkan lapangan, tanggal (weekday/weekend), dan slot waktu (prime time atau tidak). Field-field harga menggunakan reactive() agar kalkulasi berjalan real-time saat admin mengisi form.
Di bagian tabel, saya menambahkan banyak filter yang sangat berguna seperti filter berdasarkan rentang tanggal, filter untuk booking hari ini, dan filter untuk upcoming booking.
Yang paling penting adalah action-action custom yang saya tambahkan: markAsPaid untuk menandai booking sudah dibayar, cancel untuk membatalkan booking dengan alasan, dan complete untuk menandai booking sudah selesai. Action-action ini memanfaatkan method yang sudah saya definisikan di model Booking sebelumnya.
Membuat View Page untuk Booking
Karena saya menambahkan route view di BookingResource, saya perlu membuat file page-nya:
php artisan make:filament-page ViewBooking --resource=BookingResource --type=ViewRecord
Ini akan membuat halaman view yang menampilkan detail booking dengan format yang lebih rapi.
Saya juga perlu membuat hal yang sama untuk MemberResource:
php artisan make:filament-page ViewMember --resource=MemberResource --type=ViewRecord
Mengecek Hasil di Browser
Sekarang saya bisa mengakses halaman admin dan melihat semua resource yang sudah dibuat. Di sidebar akan muncul menu-menu baru yang dikelompokkan berdasarkan navigation group:
Booking Management:
- Booking (dengan badge jumlah pending)
- Member
Master Data:
- Tipe Lapangan
- Lapangan
- Slot Waktu
Saya bisa mulai mengisi data master seperti tipe lapangan, lapangan, dan slot waktu. Setelah itu, saya bisa menambahkan member dan membuat booking baru.
Sampai di sini saya sudah berhasil membuat semua resource untuk CRUD data di sistem booking lapangan tenis dan padel. Di bagian selanjutnya, saya akan menambahkan fitur authentication menggunakan Laravel Breeze untuk mengamankan akses ke halaman admin dan member area.
Bagian 6: Menambahkan Fitur Authentication dengan Laravel Breeze
Sekarang saya akan menambahkan fitur authentication untuk mengamankan akses ke halaman admin dan juga membuat member area dimana member bisa login untuk melakukan booking sendiri. Saya memilih menggunakan Laravel Breeze karena package ini menyediakan scaffolding authentication yang simple, clean, dan mudah dikustomisasi.
Mengapa Saya Memilih Laravel Breeze
Sebelum saya mulai instalasi, saya ingin menjelaskan kenapa saya memilih Breeze dibanding Fortify atau Jetstream. Laravel Breeze adalah pilihan yang tepat untuk projek seperti ini karena menyediakan fitur authentication dasar yang lengkap tanpa overhead yang berlebihan. Breeze sudah include fitur login, register, password reset, email verification, dan profile management. Tampilannya menggunakan Blade dan Tailwind CSS yang bisa saya kustomisasi dengan mudah.
Kalau teman-teman butuh fitur yang lebih advanced seperti two-factor authentication atau team management, baru saya sarankan menggunakan Jetstream. Tapi untuk projek booking lapangan ini, Breeze sudah lebih dari cukup.
Menginstall Laravel Breeze
Pertama, saya install package Laravel Breeze menggunakan Composer:
composer require laravel/breeze --dev
Setelah package terinstall, saya jalankan perintah untuk mempublish file-file Breeze:
php artisan breeze:install blade
Saya memilih blade sebagai stack karena saya ingin menggunakan Blade templating yang sudah familiar. Breeze juga menyediakan opsi lain seperti React, Vue, atau API only, tapi untuk projek ini Blade sudah cukup.
Selama proses instalasi, Breeze akan menanyakan beberapa pertanyaan:
Would you like dark mode support? (yes/no) [no]
> yes
Which testing framework do you prefer? [Pest]
> Pest
Saya pilih yes untuk dark mode support karena fitur ini sangat disukai user modern. Untuk testing framework, saya pilih Pest karena syntaxnya lebih clean.
Setelah proses instalasi selesai, saya perlu menginstall npm dependencies dan build assets:
npm install
npm run build
Kemudian jalankan migration untuk membuat tabel-tabel yang dibutuhkan Breeze (sebenarnya tabel users sudah ada dari migration sebelumnya):
php artisan migrate
Mengecek Fitur Authentication yang Terinstall
Sekarang saya cek fitur-fitur apa saja yang sudah disediakan oleh Breeze. Saya buka file routes/auth.php untuk melihat route-route yang sudah dibuat:
<?php
use App\\Http\\Controllers\\Auth\\AuthenticatedSessionController;
use App\\Http\\Controllers\\Auth\\ConfirmablePasswordController;
use App\\Http\\Controllers\\Auth\\EmailVerificationNotificationController;
use App\\Http\\Controllers\\Auth\\EmailVerificationPromptController;
use App\\Http\\Controllers\\Auth\\NewPasswordController;
use App\\Http\\Controllers\\Auth\\PasswordController;
use App\\Http\\Controllers\\Auth\\PasswordResetLinkController;
use App\\Http\\Controllers\\Auth\\RegisteredUserController;
use App\\Http\\Controllers\\Auth\\VerifyEmailController;
use Illuminate\\Support\\Facades\\Route;
Route::middleware('guest')->group(function () {
Route::get('register', [RegisteredUserController::class, 'create'])
->name('register');
Route::post('register', [RegisteredUserController::class, 'store']);
Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login');
Route::post('login', [AuthenticatedSessionController::class, 'store']);
Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
->name('password.request');
Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
->name('password.email');
Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
->name('password.reset');
Route::post('reset-password', [NewPasswordController::class, 'store'])
->name('password.store');
});
Route::middleware('auth')->group(function () {
Route::get('verify-email', EmailVerificationPromptController::class)
->name('verification.notice');
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
->middleware(['signed', 'throttle:6,1'])
->name('verification.verify');
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
->middleware('throttle:6,1')
->name('verification.send');
Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
->name('password.confirm');
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
Route::put('password', [PasswordController::class, 'update'])->name('password.update');
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
->name('logout');
});
Breeze sudah menyediakan semua route yang dibutuhkan untuk authentication lengkap. Saya tinggal kustomisasi tampilannya sesuai kebutuhan.
Mengkustomisasi Halaman Register
Saya ingin mengkustomisasi halaman register agar ketika user mendaftar, otomatis juga dibuat data member. Pertama, saya buka file controller app/Http/Controllers/Auth/RegisteredUserController.php:
<?php
namespace App\\Http\\Controllers\\Auth;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Member;
use App\\Models\\User;
use Illuminate\\Auth\\Events\\Registered;
use Illuminate\\Http\\RedirectResponse;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;
use Illuminate\\Support\\Facades\\Hash;
use Illuminate\\Validation\\Rules;
use Illuminate\\View\\View;
class RegisteredUserController extends Controller
{
public function create(): View
{
return view('auth.register');
}
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'phone' => ['required', 'string', 'max:20'],
'password' => ['required', 'confirmed', Rules\\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
Member::create([
'user_id' => $user->id,
'name' => $request->name,
'email' => $request->email,
'phone' => $request->phone,
'membership_type' => 'regular',
'is_active' => true,
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('member.dashboard', absolute: false));
}
}
Saya menambahkan validasi untuk field phone dan juga membuat record Member setelah user berhasil dibuat. Dengan cara ini, setiap user yang register akan otomatis menjadi member dengan tipe regular.
Selanjutnya saya perlu mengupdate view register untuk menambahkan field phone. Saya buka file resources/views/auth/register.blade.php:
<x-guest-layout>
<form method="POST" action="{{ route('register') }}">
@csrf
<!-- Name -->
<div>
<x-input-label for="name" :value="__('Nama Lengkap')" />
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
<!-- Email Address -->
<div class="mt-4">
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Phone -->
<div class="mt-4">
<x-input-label for="phone" :value="__('No. Telepon')" />
<x-text-input id="phone" class="block mt-1 w-full" type="tel" name="phone" :value="old('phone')" required autocomplete="tel" placeholder="08xxxxxxxxxx" />
<x-input-error :messages="$errors->get('phone')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Konfirmasi Password')" />
<x-text-input id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<a class="underline text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-800" href="{{ route('login') }}">
{{ __('Sudah punya akun?') }}
</a>
<x-primary-button class="ms-4">
{{ __('Daftar') }}
</x-primary-button>
</div>
</form>
</x-guest-layout>
Membuat Member Dashboard
Sekarang saya perlu membuat halaman dashboard untuk member. Pertama, saya buat controller baru:
php artisan make:controller Member/DashboardController
Saya buka file app/Http/Controllers/Member/DashboardController.php dan isi:
<?php
namespace App\\Http\\Controllers\\Member;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Booking;
use Illuminate\\Http\\Request;
use Illuminate\\View\\View;
class DashboardController extends Controller
{
public function index(Request $request): View
{
$user = $request->user();
$member = $user->member;
$upcomingBookings = Booking::where('member_id', $member->id)
->where('booking_date', '>=', now()->toDateString())
->whereNotIn('booking_status', ['cancelled', 'completed'])
->with(['court', 'timeSlot'])
->orderBy('booking_date')
->orderBy('time_slot_id')
->take(5)
->get();
$recentBookings = Booking::where('member_id', $member->id)
->with(['court', 'timeSlot'])
->orderBy('created_at', 'desc')
->take(10)
->get();
$stats = [
'total_bookings' => $member->total_bookings,
'total_spent' => $member->total_spent,
'membership_type' => $member->membership_type,
'discount_percentage' => $member->discount_percentage,
];
return view('member.dashboard', compact('member', 'upcomingBookings', 'recentBookings', 'stats'));
}
}
Membuat Route untuk Member Area
Saya buka file routes/web.php dan tambahkan route untuk member area:
<?php
use App\\Http\\Controllers\\Member\\DashboardController;
use App\\Http\\Controllers\\Member\\BookingController;
use App\\Http\\Controllers\\ProfileController;
use Illuminate\\Support\\Facades\\Route;
Route::get('/', function () {
return view('welcome');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
Route::middleware(['auth', 'verified'])->prefix('member')->name('member.')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/booking', [BookingController::class, 'index'])->name('booking.index');
Route::get('/booking/create', [BookingController::class, 'create'])->name('booking.create');
Route::post('/booking', [BookingController::class, 'store'])->name('booking.store');
Route::get('/booking/{booking}', [BookingController::class, 'show'])->name('booking.show');
Route::post('/booking/{booking}/cancel', [BookingController::class, 'cancel'])->name('booking.cancel');
Route::get('/history', [BookingController::class, 'history'])->name('booking.history');
});
require __DIR__.'/auth.php';
Membuat View untuk Member Dashboard
Saya buat folder dan file view untuk member dashboard. Pertama, saya buat layout khusus untuk member area. Saya buat file resources/views/layouts/member.blade.php:
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Court Booking') }} - Member Area</title>
<!-- Fonts -->
<link rel="preconnect" href="<https://fonts.bunny.net>">
<link href="<https://fonts.bunny.net/css?family=poppins:400,500,600,700&display=swap>" rel="stylesheet" />
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased h-full bg-gray-100 dark:bg-gray-900">
<div class="min-h-full">
<!-- Navigation -->
<nav class="bg-emerald-600 dark:bg-emerald-800">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-white text-xl font-bold">🎾 Court Booking</span>
</div>
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<a href="{{ route('member.dashboard') }}" class="{{ request()->routeIs('member.dashboard') ? 'bg-emerald-700 text-white' : 'text-emerald-100 hover:bg-emerald-500' }} rounded-md px-3 py-2 text-sm font-medium">
Dashboard
</a>
<a href="{{ route('member.booking.create') }}" class="{{ request()->routeIs('member.booking.create') ? 'bg-emerald-700 text-white' : 'text-emerald-100 hover:bg-emerald-500' }} rounded-md px-3 py-2 text-sm font-medium">
Booking Baru
</a>
<a href="{{ route('member.booking.history') }}" class="{{ request()->routeIs('member.booking.history') ? 'bg-emerald-700 text-white' : 'text-emerald-100 hover:bg-emerald-500' }} rounded-md px-3 py-2 text-sm font-medium">
Riwayat Booking
</a>
</div>
</div>
</div>
<div class="hidden md:block">
<div class="ml-4 flex items-center md:ml-6">
<div class="relative ml-3">
<div class="flex items-center space-x-4">
<span class="text-white text-sm">{{ Auth::user()->name }}</span>
<a href="{{ route('profile.edit') }}" class="text-emerald-100 hover:text-white text-sm">
Profil
</a>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<button type="submit" class="text-emerald-100 hover:text-white text-sm">
Logout
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</nav>
<!-- Page Header -->
@if(isset($header))
<header class="bg-white dark:bg-gray-800 shadow">
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{{ $header }}
</div>
</header>
@endif
<!-- Main Content -->
<main>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{{ $slot }}
</div>
</main>
</div>
</body>
</html>
Selanjutnya saya buat file resources/views/member/dashboard.blade.php:
<x-member-layout>
<x-slot name="header">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Selamat Datang, {{ $member->name }}! 👋
</h1>
</x-slot>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 bg-emerald-100 dark:bg-emerald-900 rounded-full">
<svg class="w-6 h-6 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Total Booking</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ $stats['total_bookings'] }}</p>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 bg-blue-100 dark:bg-blue-900 rounded-full">
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Total Pengeluaran</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">Rp {{ number_format($stats['total_spent'], 0, ',', '.') }}</p>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 bg-yellow-100 dark:bg-yellow-900 rounded-full">
<svg class="w-6 h-6 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Membership</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white capitalize">{{ $stats['membership_type'] }}</p>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6">
<div class="flex items-center">
<div class="p-3 bg-purple-100 dark:bg-purple-900 rounded-full">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Diskon Member</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ $stats['discount_percentage'] }}%</p>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Upcoming Bookings -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Booking Mendatang</h2>
<a href="{{ route('member.booking.create') }}" class="text-sm text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 font-medium">
+ Booking Baru
</a>
</div>
</div>
<div class="p-6">
@forelse($upcomingBookings as $booking)
<div class="flex items-center justify-between py-4 {{ !$loop->last ? 'border-b border-gray-100 dark:border-gray-700' : '' }}">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-emerald-100 dark:bg-emerald-900 rounded-lg flex items-center justify-center">
<span class="text-lg">🎾</span>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-white">{{ $booking->court->name }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ $booking->booking_date->format('D, d M Y') }} • {{ $booking->timeSlot->formatted_slot }}
</p>
</div>
</div>
<div class="text-right">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $booking->payment_status === 'paid' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' }}">
{{ $booking->payment_status === 'paid' ? 'Lunas' : 'Belum Bayar' }}
</span>
<p class="text-sm font-medium text-gray-900 dark:text-white mt-1">
Rp {{ number_format($booking->total_price, 0, ',', '.') }}
</p>
</div>
</div>
@empty
<div class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Belum ada booking mendatang</p>
<a href="{{ route('member.booking.create') }}" class="mt-4 inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-lg hover:bg-emerald-700">
Booking Sekarang
</a>
</div>
@endforelse
</div>
</div>
<!-- Recent Activity -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Aktivitas Terakhir</h2>
<a href="{{ route('member.booking.history') }}" class="text-sm text-emerald-600 hover:text-emerald-700 dark:text-emerald-400 font-medium">
Lihat Semua
</a>
</div>
</div>
<div class="p-6">
@forelse($recentBookings as $booking)
<div class="flex items-center justify-between py-3 {{ !$loop->last ? 'border-b border-gray-100 dark:border-gray-700' : '' }}">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ $booking->booking_code }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $booking->created_at->diffForHumans() }}</p>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
@if($booking->booking_status === 'confirmed') bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
@elseif($booking->booking_status === 'pending') bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
@elseif($booking->booking_status === 'completed') bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
@elseif($booking->booking_status === 'cancelled') bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
@else bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200
@endif">
{{ ucfirst($booking->booking_status) }}
</span>
</div>
@empty
<div class="text-center py-8">
<p class="text-sm text-gray-500 dark:text-gray-400">Belum ada aktivitas</p>
</div>
@endforelse
</div>
</div>
</div>
</x-member-layout>
Membuat Booking Controller untuk Member
Saya perlu membuat controller untuk handle booking dari sisi member:
php artisan make:controller Member/BookingController
Saya buka file app/Http/Controllers/Member/BookingController.php dan isi:
<?php
namespace App\\Http\\Controllers\\Member;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Booking;
use App\\Models\\Court;
use App\\Models\\TimeSlot;
use Illuminate\\Http\\Request;
use Illuminate\\View\\View;
use Illuminate\\Http\\RedirectResponse;
class BookingController extends Controller
{
public function index(Request $request): View
{
$member = $request->user()->member;
$bookings = Booking::where('member_id', $member->id)
->where('booking_date', '>=', now()->toDateString())
->whereNotIn('booking_status', ['cancelled', 'completed'])
->with(['court', 'timeSlot'])
->orderBy('booking_date')
->paginate(10);
return view('member.booking.index', compact('bookings'));
}
public function create(Request $request): View
{
$courts = Court::where('is_available', true)
->with('courtType')
->get();
$timeSlots = TimeSlot::where('is_active', true)
->orderBy('start_time')
->get();
$selectedDate = $request->get('date', now()->toDateString());
return view('member.booking.create', compact('courts', 'timeSlots', 'selectedDate'));
}
public function store(Request $request): RedirectResponse
{
$request->validate([
'court_id' => 'required|exists:courts,id',
'time_slot_id' => 'required|exists:time_slots,id',
'booking_date' => 'required|date|after_or_equal:today',
'notes' => 'nullable|string|max:500',
]);
$member = $request->user()->member;
$court = Court::findOrFail($request->court_id);
$timeSlot = TimeSlot::findOrFail($request->time_slot_id);
$existingBooking = Booking::where('court_id', $request->court_id)
->where('time_slot_id', $request->time_slot_id)
->where('booking_date', $request->booking_date)
->whereNotIn('booking_status', ['cancelled'])
->exists();
if ($existingBooking) {
return back()->withErrors(['time_slot_id' => 'Slot waktu ini sudah dibooking. Silakan pilih slot lain.']);
}
$basePrice = $court->getPriceForDate($request->booking_date);
if ($timeSlot->is_prime_time) {
$basePrice += $timeSlot->prime_time_surcharge;
}
$discount = ($basePrice * $member->discount_percentage) / 100;
$totalPrice = $basePrice - $discount;
$booking = Booking::create([
'member_id' => $member->id,
'court_id' => $request->court_id,
'time_slot_id' => $request->time_slot_id,
'booking_date' => $request->booking_date,
'base_price' => $basePrice,
'discount' => $discount,
'total_price' => $totalPrice,
'notes' => $request->notes,
'payment_status' => 'pending',
'booking_status' => 'pending',
]);
return redirect()->route('member.booking.show', $booking)
->with('success', 'Booking berhasil dibuat! Silakan lakukan pembayaran.');
}
public function show(Booking $booking): View
{
$this->authorize('view', $booking);
$booking->load(['court.courtType', 'timeSlot', 'member']);
return view('member.booking.show', compact('booking'));
}
public function cancel(Request $request, Booking $booking): RedirectResponse
{
$this->authorize('cancel', $booking);
$request->validate([
'reason' => 'required|string|max:500',
]);
$booking->cancel($request->reason);
return redirect()->route('member.booking.history')
->with('success', 'Booking berhasil dibatalkan.');
}
public function history(Request $request): View
{
$member = $request->user()->member;
$bookings = Booking::where('member_id', $member->id)
->with(['court', 'timeSlot'])
->orderBy('created_at', 'desc')
->paginate(15);
return view('member.booking.history', compact('bookings'));
}
}
Membuat Policy untuk Booking
Saya perlu membuat policy agar member hanya bisa melihat dan membatalkan booking miliknya sendiri:
php artisan make:policy BookingPolicy --model=Booking
Saya buka file app/Policies/BookingPolicy.php dan isi:
<?php
namespace App\\Policies;
use App\\Models\\Booking;
use App\\Models\\User;
class BookingPolicy
{
public function view(User $user, Booking $booking): bool
{
return $user->member && $user->member->id === $booking->member_id;
}
public function cancel(User $user, Booking $booking): bool
{
if (!$user->member || $user->member->id !== $booking->member_id) {
return false;
}
if (in_array($booking->booking_status, ['cancelled', 'completed'])) {
return false;
}
if ($booking->booking_date <= now()->toDateString()) {
return false;
}
return true;
}
}
Jangan lupa daftarkan policy di app/Providers/AppServiceProvider.php atau AuthServiceProvider:
<?php
namespace App\\Providers;
use App\\Models\\Booking;
use App\\Policies\\BookingPolicy;
use Illuminate\\Support\\Facades\\Gate;
use Illuminate\\Support\\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Gate::policy(Booking::class, BookingPolicy::class);
}
}
Mengkonfigurasi Redirect Setelah Login
Saya perlu mengubah redirect setelah login agar user biasa diarahkan ke member dashboard, bukan ke dashboard default. Saya buka file app/Http/Controllers/Auth/AuthenticatedSessionController.php dan modifikasi method store:
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('member.dashboard', absolute: false));
}
Memisahkan Login Admin dan Member
Untuk keamanan yang lebih baik, Filament sudah memiliki sistem login sendiri di /admin/login. Jadi member akan login melalui /login (Breeze) dan admin akan login melalui /admin/login (Filament). Kedua sistem ini menggunakan tabel users yang sama, tapi middleware dan redirect-nya berbeda.
Saya perlu memastikan bahwa hanya user dengan role admin yang bisa mengakses panel Filament. Ini akan saya implementasikan di bagian selanjutnya ketika saya setup Spatie Permission.
Mengecek Hasil di Browser
Sekarang saya bisa mengetes fitur authentication yang sudah dibuat:
Untuk member, saya akses http://localhost:8000/register untuk mendaftar akun baru. Setelah mendaftar, saya akan otomatis login dan diarahkan ke member dashboard.
Saya juga bisa akses http://localhost:8000/login untuk login dengan akun yang sudah terdaftar.
Untuk admin, saya tetap mengakses http://localhost:8000/admin/login dan login dengan akun admin yang sudah dibuat sebelumnya.
Sampai di sini saya sudah berhasil menambahkan fitur authentication dengan Laravel Breeze. Di bagian selanjutnya, saya akan mengatur role dan permission menggunakan Spatie Laravel Permission agar ada pembagian akses yang jelas antara admin, staff, dan member.
Bagian 7: Mengatur Role dan Permission dengan Spatie Laravel Permission
Sekarang saya akan mengimplementasikan sistem role dan permission menggunakan package Spatie Laravel Permission. Ini adalah salah satu bagian paling penting dalam membangun aplikasi yang memiliki banyak jenis pengguna dengan hak akses yang berbeda-beda. Dengan Spatie Permission, saya bisa mengatur secara detail siapa yang boleh melakukan apa di dalam aplikasi.
Mengapa Perlu Role dan Permission
Sebelum saya mulai instalasi, saya ingin menjelaskan kenapa sistem role dan permission sangat penting untuk projek booking lapangan ini. Bayangkan skenario berikut:
Admin adalah orang yang memiliki akses penuh ke seluruh sistem. Admin bisa mengelola lapangan, mengatur harga, melihat semua booking, mengubah status pembayaran, dan melihat laporan keuangan.
Manager adalah orang yang bertanggung jawab atas operasional venue. Manager bisa melihat semua booking dan laporan, tapi tidak bisa mengubah data master seperti harga lapangan atau menghapus data penting.
Staff Operasional adalah orang yang bertugas di lapangan. Mereka hanya perlu melihat jadwal booking hari ini, mengkonfirmasi kehadiran customer, dan menandai booking sebagai selesai.
Kasir adalah orang yang menangani pembayaran di tempat. Kasir bisa melihat booking yang belum dibayar dan memproses pembayaran, tapi tidak bisa membatalkan booking atau mengakses laporan keuangan.
Dengan Spatie Permission, saya bisa membuat pembagian akses seperti ini dengan mudah dan fleksibel.
Menginstall Spatie Laravel Permission
Pertama, saya install package menggunakan Composer:
composer require spatie/laravel-permission
Setelah package terinstall, saya publish file migration dan konfigurasi:
php artisan vendor:publish --provider="Spatie\\Permission\\PermissionServiceProvider"
Perintah ini akan membuat file migration di folder database/migrations dan file config di config/permission.php.
Selanjutnya saya jalankan migration untuk membuat tabel-tabel yang dibutuhkan:
php artisan migrate
Spatie akan membuat beberapa tabel: roles, permissions, model_has_roles, model_has_permissions, dan role_has_permissions.
Menambahkan Trait ke Model User
Saya perlu menambahkan trait HasRoles ke model User agar bisa menggunakan fitur role dan permission. Saya buka file app/Models/User.php dan modifikasi:
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Relations\\HasOne;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
use Spatie\\Permission\\Traits\\HasRoles;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasRoles;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function member(): HasOne
{
return $this->hasOne(Member::class);
}
public function isMember(): bool
{
return $this->member !== null;
}
public function isAdmin(): bool
{
return $this->hasRole('admin');
}
public function isManager(): bool
{
return $this->hasRole('manager');
}
public function isStaff(): bool
{
return $this->hasRole(['staff_operasional', 'kasir']);
}
}
Saya juga menambahkan beberapa helper method seperti isAdmin(), isManager(), dan isStaff() untuk memudahkan pengecekan role di berbagai tempat.
Membuat Seeder untuk Role dan Permission
Sekarang saya akan membuat seeder untuk mendefinisikan semua role dan permission yang dibutuhkan. Saya buat file seeder baru:
php artisan make:seeder RolePermissionSeeder
Saya buka file database/seeders/RolePermissionSeeder.php dan isi:
<?php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
use Spatie\\Permission\\Models\\Permission;
use Spatie\\Permission\\Models\\Role;
class RolePermissionSeeder extends Seeder
{
public function run(): void
{
app()[\\Spatie\\Permission\\PermissionRegistrar::class]->forgetCachedPermissions();
$permissions = [
'view_court_types',
'create_court_types',
'edit_court_types',
'delete_court_types',
'view_courts',
'create_courts',
'edit_courts',
'delete_courts',
'view_time_slots',
'create_time_slots',
'edit_time_slots',
'delete_time_slots',
'view_members',
'create_members',
'edit_members',
'delete_members',
'upgrade_membership',
'view_bookings',
'create_bookings',
'edit_bookings',
'delete_bookings',
'confirm_bookings',
'cancel_bookings',
'complete_bookings',
'view_payments',
'process_payments',
'refund_payments',
'view_reports',
'export_reports',
'manage_settings',
'manage_users',
'manage_roles',
];
foreach ($permissions as $permission) {
Permission::create(['name' => $permission]);
}
$adminRole = Role::create(['name' => 'admin']);
$adminRole->givePermissionTo(Permission::all());
$managerRole = Role::create(['name' => 'manager']);
$managerRole->givePermissionTo([
'view_court_types',
'view_courts',
'edit_courts',
'view_time_slots',
'view_members',
'edit_members',
'upgrade_membership',
'view_bookings',
'create_bookings',
'edit_bookings',
'confirm_bookings',
'cancel_bookings',
'complete_bookings',
'view_payments',
'process_payments',
'view_reports',
'export_reports',
]);
$staffOperasionalRole = Role::create(['name' => 'staff_operasional']);
$staffOperasionalRole->givePermissionTo([
'view_courts',
'view_time_slots',
'view_members',
'view_bookings',
'confirm_bookings',
'complete_bookings',
]);
$kasirRole = Role::create(['name' => 'kasir']);
$kasirRole->givePermissionTo([
'view_members',
'view_bookings',
'view_payments',
'process_payments',
]);
$memberRole = Role::create(['name' => 'member']);
}
}
Saya jelaskan struktur permission yang saya buat. Saya mengelompokkan permission berdasarkan resource dan action. Misalnya untuk booking, saya buat permission terpisah untuk view, create, edit, delete, confirm, cancel, dan complete. Dengan cara ini, saya bisa mengatur akses secara granular.
Untuk role admin, saya berikan semua permission. Untuk manager, saya berikan permission yang cukup luas tapi tidak termasuk delete dan manage settings. Untuk staff operasional, saya hanya berikan permission untuk view dan konfirmasi booking. Dan untuk kasir, saya fokuskan pada permission yang berhubungan dengan pembayaran.
Menjalankan Seeder
Saya perlu menambahkan seeder ke DatabaseSeeder.php:
<?php
namespace Database\\Seeders;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
RolePermissionSeeder::class,
]);
}
}
Kemudian jalankan seeder:
php artisan db:seed --class=RolePermissionSeeder
Membuat Seeder untuk Admin User
Saya juga akan membuat seeder untuk membuat user admin default:
php artisan make:seeder AdminUserSeeder
Isi file database/seeders/AdminUserSeeder.php:
<?php
namespace Database\\Seeders;
use App\\Models\\User;
use Illuminate\\Database\\Seeder;
use Illuminate\\Support\\Facades\\Hash;
class AdminUserSeeder extends Seeder
{
public function run(): void
{
$admin = User::create([
'name' => 'Super Admin',
'email' => '[email protected]',
'password' => Hash::make('password123'),
'email_verified_at' => now(),
]);
$admin->assignRole('admin');
$manager = User::create([
'name' => 'Manager Venue',
'email' => '[email protected]',
'password' => Hash::make('password123'),
'email_verified_at' => now(),
]);
$manager->assignRole('manager');
$staff = User::create([
'name' => 'Staff Operasional',
'email' => '[email protected]',
'password' => Hash::make('password123'),
'email_verified_at' => now(),
]);
$staff->assignRole('staff_operasional');
$kasir = User::create([
'name' => 'Kasir',
'email' => '[email protected]',
'password' => Hash::make('password123'),
'email_verified_at' => now(),
]);
$kasir->assignRole('kasir');
}
}
Jalankan seeder:
php artisan db:seed --class=AdminUserSeeder
Mengintegrasikan Permission dengan Filament
Sekarang saya akan mengintegrasikan Spatie Permission dengan Filament agar akses ke panel admin dan setiap resource bisa dikontrol berdasarkan role dan permission.
Pertama, saya install plugin Filament Shield yang mempermudah integrasi ini:
composer require bezhansalleh/filament-shield
Setelah terinstall, saya publish konfigurasinya:
php artisan vendor:publish --tag=filament-shield-config
Kemudian saya jalankan setup wizard:
php artisan shield:setup
Shield akan menanyakan beberapa pertanyaan. Saya pilih untuk generate permission untuk semua resource yang sudah ada.
Selanjutnya saya perlu menambahkan trait ke setiap Resource. Tapi karena ini cukup repetitif, saya akan menunjukkan cara manual yang lebih fleksibel.
Mengamankan Akses ke Panel Admin
Saya perlu memastikan hanya user dengan role tertentu yang bisa mengakses panel Filament. Saya buka file app/Providers/Filament/AdminPanelProvider.php dan tambahkan middleware custom:
<?php
namespace App\\Providers\\Filament;
use App\\Http\\Middleware\\CheckAdminRole;
use Filament\\Http\\Middleware\\Authenticate;
use Filament\\Http\\Middleware\\DisableBladeIconComponents;
use Filament\\Http\\Middleware\\DispatchServingFilamentEvent;
use Filament\\Pages;
use Filament\\Panel;
use Filament\\PanelProvider;
use Filament\\Support\\Colors\\Color;
use Filament\\Widgets;
use Illuminate\\Cookie\\Middleware\\AddQueuedCookiesToResponse;
use Illuminate\\Cookie\\Middleware\\EncryptCookies;
use Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken;
use Illuminate\\Routing\\Middleware\\SubstituteBindings;
use Illuminate\\Session\\Middleware\\AuthenticateSession;
use Illuminate\\Session\\Middleware\\StartSession;
use Illuminate\\View\\Middleware\\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->brandName('Court Booking Admin')
->colors([
'primary' => Color::Emerald,
'danger' => Color::Red,
'warning' => Color::Amber,
'success' => Color::Green,
'info' => Color::Blue,
])
->font('Poppins')
->sidebarCollapsibleOnDesktop()
->navigationGroups([
'Booking Management',
'Master Data',
'User Management',
'Reports',
'Settings',
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\\\Filament\\\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\\\Filament\\\\Pages')
->pages([
Pages\\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\\\Filament\\\\Widgets')
->widgets([
Widgets\\AccountWidget::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
])
->authGuard('web');
}
}
Sekarang saya buat middleware untuk mengecek apakah user memiliki role yang diizinkan:
php artisan make:middleware CheckAdminRole
Saya buka file app/Http/Middleware/CheckAdminRole.php:
<?php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
class CheckAdminRole
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user()) {
return redirect()->route('filament.admin.auth.login');
}
$allowedRoles = ['admin', 'manager', 'staff_operasional', 'kasir'];
if (!$request->user()->hasAnyRole($allowedRoles)) {
abort(403, 'Anda tidak memiliki akses ke halaman admin.');
}
return $next($request);
}
}
Daftarkan middleware di bootstrap/app.php:
<?php
use Illuminate\\Foundation\\Application;
use Illuminate\\Foundation\\Configuration\\Exceptions;
use Illuminate\\Foundation\\Configuration\\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'check.admin.role' => \\App\\Http\\Middleware\\CheckAdminRole::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
Menambahkan Permission ke Setiap Resource
Sekarang saya akan menambahkan pengecekan permission ke setiap resource. Saya mulai dengan BookingResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\BookingResource\\Pages;
use App\\Models\\Booking;
use App\\Models\\Court;
use App\\Models\\TimeSlot;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
class BookingResource extends Resource
{
protected static ?string $model = Booking::class;
protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
protected static ?string $navigationLabel = 'Booking';
protected static ?string $modelLabel = 'Booking';
protected static ?string $pluralModelLabel = 'Booking';
protected static ?string $navigationGroup = 'Booking Management';
protected static ?int $navigationSort = 1;
public static function canViewAny(): bool
{
return auth()->user()->can('view_bookings');
}
public static function canCreate(): bool
{
return auth()->user()->can('create_bookings');
}
public static function canEdit($record): bool
{
return auth()->user()->can('edit_bookings');
}
public static function canDelete($record): bool
{
return auth()->user()->can('delete_bookings');
}
// ... rest of the resource code
}
Saya juga perlu mengupdate table actions agar action-action tertentu hanya muncul untuk user dengan permission yang sesuai:
public static function table(Table $table): Table
{
return $table
->columns([
// ... columns
])
->filters([
// ... filters
])
->actions([
Tables\\Actions\\ActionGroup::make([
Tables\\Actions\\ViewAction::make()
->visible(fn () => auth()->user()->can('view_bookings')),
Tables\\Actions\\EditAction::make()
->visible(fn () => auth()->user()->can('edit_bookings')),
Tables\\Actions\\Action::make('markAsPaid')
->label('Tandai Lunas')
->icon('heroicon-o-check-circle')
->color('success')
->visible(fn (Booking $record) =>
$record->payment_status === 'pending' &&
auth()->user()->can('process_payments')
)
->form([
Forms\\Components\\Select::make('payment_method')
->label('Metode Pembayaran')
->options([
'cash' => 'Cash',
'transfer' => 'Bank Transfer',
'qris' => 'QRIS',
])
->required(),
])
->action(function (Booking $record, array $data) {
$record->markAsPaid($data['payment_method']);
})
->requiresConfirmation(),
Tables\\Actions\\Action::make('confirm')
->label('Konfirmasi')
->icon('heroicon-o-check')
->color('success')
->visible(fn (Booking $record) =>
$record->booking_status === 'pending' &&
$record->payment_status === 'paid' &&
auth()->user()->can('confirm_bookings')
)
->action(fn (Booking $record) => $record->update(['booking_status' => 'confirmed']))
->requiresConfirmation(),
Tables\\Actions\\Action::make('complete')
->label('Selesai')
->icon('heroicon-o-flag')
->color('info')
->visible(fn (Booking $record) =>
$record->booking_status === 'confirmed' &&
auth()->user()->can('complete_bookings')
)
->action(fn (Booking $record) => $record->complete())
->requiresConfirmation(),
Tables\\Actions\\Action::make('cancel')
->label('Batalkan')
->icon('heroicon-o-x-circle')
->color('danger')
->visible(fn (Booking $record) =>
!in_array($record->booking_status, ['cancelled', 'completed']) &&
auth()->user()->can('cancel_bookings')
)
->form([
Forms\\Components\\Textarea::make('reason')
->label('Alasan Pembatalan')
->required(),
])
->action(function (Booking $record, array $data) {
$record->cancel($data['reason']);
})
->requiresConfirmation(),
Tables\\Actions\\DeleteAction::make()
->visible(fn () => auth()->user()->can('delete_bookings')),
]),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make()
->visible(fn () => auth()->user()->can('delete_bookings')),
]),
]);
}
Menambahkan Permission ke Resource Lainnya
Saya terapkan pola yang sama untuk resource lainnya. Berikut contoh untuk CourtResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\CourtResource\\Pages;
use App\\Models\\Court;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class CourtResource extends Resource
{
protected static ?string $model = Court::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office';
protected static ?string $navigationLabel = 'Lapangan';
protected static ?string $modelLabel = 'Lapangan';
protected static ?string $pluralModelLabel = 'Lapangan';
protected static ?string $navigationGroup = 'Master Data';
protected static ?int $navigationSort = 2;
public static function canViewAny(): bool
{
return auth()->user()->can('view_courts');
}
public static function canCreate(): bool
{
return auth()->user()->can('create_courts');
}
public static function canEdit($record): bool
{
return auth()->user()->can('edit_courts');
}
public static function canDelete($record): bool
{
return auth()->user()->can('delete_courts');
}
// ... rest of the code
}
Dan untuk MemberResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\MemberResource\\Pages;
use App\\Models\\Member;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
class MemberResource extends Resource
{
protected static ?string $model = Member::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationLabel = 'Member';
protected static ?string $modelLabel = 'Member';
protected static ?string $pluralModelLabel = 'Member';
protected static ?string $navigationGroup = 'Booking Management';
protected static ?int $navigationSort = 2;
public static function canViewAny(): bool
{
return auth()->user()->can('view_members');
}
public static function canCreate(): bool
{
return auth()->user()->can('create_members');
}
public static function canEdit($record): bool
{
return auth()->user()->can('edit_members');
}
public static function canDelete($record): bool
{
return auth()->user()->can('delete_members');
}
public static function table(Table $table): Table
{
return $table
->columns([
// ... columns
])
->actions([
Tables\\Actions\\ActionGroup::make([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\Action::make('upgradeMembership')
->label('Upgrade Membership')
->icon('heroicon-o-arrow-up-circle')
->color('success')
->visible(fn () => auth()->user()->can('upgrade_membership'))
->form([
Forms\\Components\\Select::make('membership_type')
->label('Tipe Membership Baru')
->options([
'silver' => 'Silver (Diskon 10%)',
'gold' => 'Gold (Diskon 15%)',
'platinum' => 'Platinum (Diskon 20%)',
])
->required(),
Forms\\Components\\DatePicker::make('expired_at')
->label('Berlaku Sampai')
->required()
->minDate(now()),
])
->action(function (Member $record, array $data) {
$record->update([
'membership_type' => $data['membership_type'],
'membership_expired_at' => $data['expired_at'],
]);
}),
Tables\\Actions\\DeleteAction::make(),
]),
]);
}
// ... rest of the code
}
Membuat Resource untuk Manajemen User dan Role
Saya juga perlu membuat resource untuk mengelola user dan role. Pertama, saya buat UserResource:
php artisan make:filament-resource User --generate
Saya buka dan modifikasi app/Filament/Resources/UserResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\UserResource\\Pages;
use App\\Models\\User;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Support\\Facades\\Hash;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-user-group';
protected static ?string $navigationLabel = 'Users';
protected static ?string $modelLabel = 'User';
protected static ?string $pluralModelLabel = 'Users';
protected static ?string $navigationGroup = 'User Management';
protected static ?int $navigationSort = 1;
public static function canViewAny(): bool
{
return auth()->user()->can('manage_users');
}
public static function canCreate(): bool
{
return auth()->user()->can('manage_users');
}
public static function canEdit($record): bool
{
return auth()->user()->can('manage_users');
}
public static function canDelete($record): bool
{
return auth()->user()->can('manage_users') && $record->id !== auth()->id();
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi User')
->schema([
Forms\\Components\\TextInput::make('name')
->label('Nama')
->required()
->maxLength(255),
Forms\\Components\\TextInput::make('email')
->label('Email')
->email()
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Forms\\Components\\TextInput::make('password')
->label('Password')
->password()
->required(fn (string $context): bool => $context === 'create')
->dehydrateStateUsing(fn ($state) => $state ? Hash::make($state) : null)
->dehydrated(fn ($state) => filled($state))
->maxLength(255)
->helperText(fn (string $context): string =>
$context === 'edit' ? 'Kosongkan jika tidak ingin mengubah password' : ''
),
Forms\\Components\\Select::make('roles')
->label('Role')
->relationship('roles', 'name')
->multiple()
->preload()
->searchable(),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('name')
->label('Nama')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('email')
->label('Email')
->searchable()
->sortable(),
Tables\\Columns\\TextColumn::make('roles.name')
->label('Role')
->badge()
->color(fn (string $state): string => match ($state) {
'admin' => 'danger',
'manager' => 'warning',
'staff_operasional' => 'info',
'kasir' => 'success',
'member' => 'gray',
default => 'gray',
}),
Tables\\Columns\\TextColumn::make('email_verified_at')
->label('Verified')
->dateTime('d M Y')
->placeholder('Belum verified')
->sortable(),
Tables\\Columns\\TextColumn::make('created_at')
->label('Dibuat')
->dateTime('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\\Filters\\SelectFilter::make('roles')
->relationship('roles', 'name')
->preload()
->multiple(),
])
->actions([
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make()
->visible(fn (User $record) => $record->id !== auth()->id()),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListUsers::route('/'),
'create' => Pages\\CreateUser::route('/create'),
'edit' => Pages\\EditUser::route('/{record}/edit'),
];
}
}
Membuat Resource untuk Role
Saya juga buat resource untuk mengelola role:
php artisan make:filament-resource Role --generate
Modifikasi app/Filament/Resources/RoleResource.php:
<?php
namespace App\\Filament\\Resources;
use App\\Filament\\Resources\\RoleResource\\Pages;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Spatie\\Permission\\Models\\Role;
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static ?string $navigationIcon = 'heroicon-o-shield-check';
protected static ?string $navigationLabel = 'Roles';
protected static ?string $modelLabel = 'Role';
protected static ?string $pluralModelLabel = 'Roles';
protected static ?string $navigationGroup = 'User Management';
protected static ?int $navigationSort = 2;
public static function canViewAny(): bool
{
return auth()->user()->can('manage_roles');
}
public static function canCreate(): bool
{
return auth()->user()->can('manage_roles');
}
public static function canEdit($record): bool
{
return auth()->user()->can('manage_roles') && $record->name !== 'admin';
}
public static function canDelete($record): bool
{
return auth()->user()->can('manage_roles') && !in_array($record->name, ['admin', 'member']);
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\\Components\\Section::make('Informasi Role')
->schema([
Forms\\Components\\TextInput::make('name')
->label('Nama Role')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Forms\\Components\\Select::make('permissions')
->label('Permissions')
->relationship('permissions', 'name')
->multiple()
->preload()
->searchable()
->columnSpanFull(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make('name')
->label('Nama Role')
->searchable()
->sortable()
->badge()
->color(fn (string $state): string => match ($state) {
'admin' => 'danger',
'manager' => 'warning',
'staff_operasional' => 'info',
'kasir' => 'success',
'member' => 'gray',
default => 'primary',
}),
Tables\\Columns\\TextColumn::make('permissions_count')
->label('Jumlah Permission')
->counts('permissions')
->badge()
->color('info'),
Tables\\Columns\\TextColumn::make('users_count')
->label('Jumlah User')
->counts('users')
->badge()
->color('success'),
Tables\\Columns\\TextColumn::make('created_at')
->label('Dibuat')
->dateTime('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\\Actions\\ViewAction::make(),
Tables\\Actions\\EditAction::make(),
Tables\\Actions\\DeleteAction::make()
->visible(fn (Role $record) => !in_array($record->name, ['admin', 'member'])),
])
->bulkActions([
Tables\\Actions\\BulkActionGroup::make([
Tables\\Actions\\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\\ListRoles::route('/'),
'create' => Pages\\CreateRole::route('/create'),
'edit' => Pages\\EditRole::route('/{record}/edit'),
];
}
}
Menyembunyikan Navigation Berdasarkan Permission
Untuk menyembunyikan menu navigation yang tidak boleh diakses user, saya bisa menggunakan method shouldRegisterNavigation() di setiap resource. Tapi karena saya sudah menggunakan canViewAny(), Filament secara otomatis akan menyembunyikan menu jika user tidak punya akses.
Mengecek Hasil Implementasi
Sekarang saya bisa login dengan akun yang berbeda untuk melihat perbedaan akses:
Login sebagai Admin ([email protected]) - Akan melihat semua menu dan bisa melakukan semua aksi.
Login sebagai Manager ([email protected]) - Akan melihat menu booking, member, dan lapangan, tapi tidak bisa menghapus data atau mengakses pengaturan.
Login sebagai Staff Operasional ([email protected]) - Hanya akan melihat menu booking dan member, dengan aksi terbatas hanya untuk konfirmasi dan menyelesaikan booking.
Login sebagai Kasir ([email protected]) - Hanya akan melihat menu booking dan member, dengan aksi khusus untuk memproses pembayaran.
Sampai di sini saya sudah berhasil mengimplementasikan sistem role dan permission yang comprehensive. Di bagian selanjutnya, saya akan menambahkan integrasi dengan Midtrans untuk memproses pembayaran online.
Bagian 8: Integrasi Payment Gateway Midtrans
Sekarang saya masuk ke bagian yang sangat penting untuk bisnis booking lapangan, yaitu integrasi dengan payment gateway Midtrans. Dengan fitur ini, member bisa langsung membayar booking mereka secara online melalui berbagai metode pembayaran seperti transfer bank, e-wallet, kartu kredit, dan QRIS. Saya sangat excited untuk mengimplementasikan ini karena akan membuat proses booking menjadi lebih seamless.
Mengapa Saya Memilih Midtrans
Sebelum saya mulai, saya ingin menjelaskan kenapa saya memilih Midtrans sebagai payment gateway. Midtrans adalah salah satu payment gateway terpopuler di Indonesia yang sudah terintegrasi dengan banyak metode pembayaran lokal. Midtrans mendukung transfer bank (BCA, BNI, BRI, Mandiri, Permata), e-wallet (GoPay, ShopeePay, OVO, Dana), kartu kredit/debit, dan QRIS. Selain itu, dokumentasinya sangat lengkap dan ada SDK untuk PHP yang memudahkan integrasi.
Membuat Akun Midtrans Sandbox
Langkah pertama yang saya lakukan adalah membuat akun di Midtrans Sandbox untuk testing. Saya buka website https://dashboard.sandbox.midtrans.com dan register akun baru. Setelah login, saya pergi ke menu Settings > Access Keys untuk mendapatkan Server Key dan Client Key yang akan digunakan untuk integrasi.
Saya catat kedua key tersebut:
- Server Key:
SB-Mid-server-xxxxxxxxxxxxxxxxxxxxxxxx - Client Key:
SB-Mid-client-xxxxxxxxxxxxxxxxxxxxxxxx
Menginstall Package Midtrans
Saya install official package Midtrans untuk PHP menggunakan Composer:
composer require midtrans/midtrans-php
Mengkonfigurasi Midtrans
Setelah package terinstall, saya perlu menambahkan konfigurasi Midtrans di file .env:
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxxxxxxxxxxxxxxxx
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxxxxxxxxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true
Kemudian saya buat file konfigurasi khusus untuk Midtrans. Saya buat file baru di config/midtrans.php:
<?php
return [
'server_key' => env('MIDTRANS_SERVER_KEY'),
'client_key' => env('MIDTRANS_CLIENT_KEY'),
'is_production' => env('MIDTRANS_IS_PRODUCTION', false),
'is_sanitized' => env('MIDTRANS_IS_SANITIZED', true),
'is_3ds' => env('MIDTRANS_IS_3DS', true),
];
Membuat Service Class untuk Midtrans
Saya akan membuat service class untuk menangani semua operasi yang berhubungan dengan Midtrans. Dengan cara ini, logic pembayaran terpisah dari controller dan lebih mudah di-maintain.
Saya buat folder dan file baru app/Services/MidtransService.php:
<?php
namespace App\\Services;
use App\\Models\\Booking;
use Midtrans\\Config;
use Midtrans\\Snap;
use Midtrans\\Transaction;
use Midtrans\\Notification;
use Exception;
class MidtransService
{
public function __construct()
{
Config::$serverKey = config('midtrans.server_key');
Config::$isProduction = config('midtrans.is_production');
Config::$isSanitized = config('midtrans.is_sanitized');
Config::$is3ds = config('midtrans.is_3ds');
}
public function createSnapToken(Booking $booking): string
{
$member = $booking->member;
$court = $booking->court;
$timeSlot = $booking->timeSlot;
$transactionDetails = [
'order_id' => $booking->booking_code,
'gross_amount' => (int) $booking->total_price,
];
$itemDetails = [
[
'id' => $court->id,
'price' => (int) $booking->base_price,
'quantity' => 1,
'name' => substr($court->name . ' - ' . $timeSlot->formatted_slot, 0, 50),
'category' => $court->courtType->name,
],
];
if ($booking->additional_charges > 0) {
$itemDetails[] = [
'id' => 'additional',
'price' => (int) $booking->additional_charges,
'quantity' => 1,
'name' => 'Biaya Tambahan',
'category' => 'Additional',
];
}
if ($booking->discount > 0) {
$itemDetails[] = [
'id' => 'discount',
'price' => (int) -$booking->discount,
'quantity' => 1,
'name' => 'Diskon Member ' . ucfirst($member->membership_type),
'category' => 'Discount',
];
}
$customerDetails = [
'first_name' => $member->name,
'email' => $member->email,
'phone' => $member->phone,
];
if ($member->address) {
$customerDetails['billing_address'] = [
'first_name' => $member->name,
'email' => $member->email,
'phone' => $member->phone,
'address' => $member->address,
'country_code' => 'IDN',
];
}
$params = [
'transaction_details' => $transactionDetails,
'item_details' => $itemDetails,
'customer_details' => $customerDetails,
'callbacks' => [
'finish' => route('member.booking.payment.finish', $booking),
],
'expiry' => [
'start_time' => now()->format('Y-m-d H:i:s O'),
'unit' => 'hours',
'duration' => 24,
],
];
try {
$snapToken = Snap::getSnapToken($params);
$booking->update([
'snap_token' => $snapToken,
]);
return $snapToken;
} catch (Exception $e) {
throw new Exception('Gagal membuat token pembayaran: ' . $e->getMessage());
}
}
public function handleNotification(): array
{
$notification = new Notification();
$transactionStatus = $notification->transaction_status;
$orderId = $notification->order_id;
$fraudStatus = $notification->fraud_status;
$paymentType = $notification->payment_type;
$transactionId = $notification->transaction_id;
$booking = Booking::where('booking_code', $orderId)->first();
if (!$booking) {
return [
'success' => false,
'message' => 'Booking tidak ditemukan',
];
}
$result = [
'success' => true,
'booking' => $booking,
'status' => $transactionStatus,
];
if ($transactionStatus == 'capture') {
if ($fraudStatus == 'accept') {
$this->markBookingAsPaid($booking, $paymentType, $transactionId);
$result['message'] = 'Pembayaran berhasil (capture)';
} else if ($fraudStatus == 'challenge') {
$booking->update(['payment_status' => 'pending']);
$result['message'] = 'Pembayaran dalam review';
}
} else if ($transactionStatus == 'settlement') {
$this->markBookingAsPaid($booking, $paymentType, $transactionId);
$result['message'] = 'Pembayaran berhasil (settlement)';
} else if ($transactionStatus == 'pending') {
$booking->update(['payment_status' => 'pending']);
$result['message'] = 'Menunggu pembayaran';
} else if ($transactionStatus == 'deny') {
$booking->update(['payment_status' => 'failed']);
$result['message'] = 'Pembayaran ditolak';
} else if ($transactionStatus == 'expire') {
$booking->update([
'payment_status' => 'expired',
'booking_status' => 'cancelled',
'cancellation_reason' => 'Pembayaran expired',
'cancelled_at' => now(),
]);
$result['message'] = 'Pembayaran expired';
} else if ($transactionStatus == 'cancel') {
$booking->update([
'payment_status' => 'failed',
'booking_status' => 'cancelled',
'cancellation_reason' => 'Pembayaran dibatalkan',
'cancelled_at' => now(),
]);
$result['message'] = 'Pembayaran dibatalkan';
}
return $result;
}
protected function markBookingAsPaid(Booking $booking, string $paymentType, string $transactionId): void
{
$booking->update([
'payment_status' => 'paid',
'booking_status' => 'confirmed',
'payment_method' => $this->mapPaymentType($paymentType),
'transaction_id' => $transactionId,
'paid_at' => now(),
]);
$booking->member->updateStatistics();
}
protected function mapPaymentType(string $midtransPaymentType): string
{
$mapping = [
'credit_card' => 'credit_card',
'bank_transfer' => 'bank_transfer',
'bca_va' => 'bank_transfer',
'bni_va' => 'bank_transfer',
'bri_va' => 'bank_transfer',
'permata_va' => 'bank_transfer',
'other_va' => 'bank_transfer',
'gopay' => 'gopay',
'shopeepay' => 'shopeepay',
'qris' => 'qris',
'cstore' => 'convenience_store',
'akulaku' => 'akulaku',
'kredivo' => 'kredivo',
];
return $mapping[$midtransPaymentType] ?? $midtransPaymentType;
}
public function getTransactionStatus(string $orderId): array
{
try {
$status = Transaction::status($orderId);
return [
'success' => true,
'data' => $status,
];
} catch (Exception $e) {
return [
'success' => false,
'message' => $e->getMessage(),
];
}
}
public function cancelTransaction(string $orderId): array
{
try {
$cancel = Transaction::cancel($orderId);
return [
'success' => true,
'data' => $cancel,
];
} catch (Exception $e) {
return [
'success' => false,
'message' => $e->getMessage(),
];
}
}
public function refundTransaction(string $orderId, int $amount, string $reason): array
{
try {
$params = [
'refund_key' => 'refund-' . $orderId . '-' . time(),
'amount' => $amount,
'reason' => $reason,
];
$refund = Transaction::refund($orderId, $params);
return [
'success' => true,
'data' => $refund,
];
} catch (Exception $e) {
return [
'success' => false,
'message' => $e->getMessage(),
];
}
}
}
Saya jelaskan beberapa method penting di service ini. Method createSnapToken() digunakan untuk membuat token Snap yang akan digunakan untuk menampilkan popup pembayaran Midtrans. Saya memasukkan detail transaksi, item yang dibeli, dan informasi customer.
Method handleNotification() adalah yang paling penting karena akan dipanggil oleh Midtrans setiap kali ada update status pembayaran. Method ini akan mengupdate status booking berdasarkan status transaksi dari Midtrans.
Menambahkan Kolom snap_token ke Tabel Bookings
Saya perlu menambahkan kolom baru untuk menyimpan snap token. Saya buat migration baru:
php artisan make:migration add_snap_token_to_bookings_table
Isi file migration:
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->string('snap_token')->nullable()->after('transaction_id');
});
}
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn('snap_token');
});
}
};
Jalankan migration:
php artisan migrate
Jangan lupa tambahkan snap_token ke fillable di model Booking:
protected $fillable = [
// ... existing fields
'snap_token',
];
Membuat Controller untuk Payment
Sekarang saya buat controller untuk menangani proses pembayaran:
php artisan make:controller Member/PaymentController
Isi file app/Http/Controllers/Member/PaymentController.php:
<?php
namespace App\\Http\\Controllers\\Member;
use App\\Http\\Controllers\\Controller;
use App\\Models\\Booking;
use App\\Services\\MidtransService;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\View\\View;
use Exception;
class PaymentController extends Controller
{
protected MidtransService $midtransService;
public function __construct(MidtransService $midtransService)
{
$this->midtransService = $midtransService;
}
public function show(Booking $booking): View
{
$this->authorize('view', $booking);
if ($booking->payment_status === 'paid') {
return redirect()->route('member.booking.show', $booking)
->with('info', 'Booking ini sudah dibayar.');
}
$booking->load(['court.courtType', 'timeSlot', 'member']);
$snapToken = $booking->snap_token;
if (!$snapToken) {
try {
$snapToken = $this->midtransService->createSnapToken($booking);
} catch (Exception $e) {
return redirect()->route('member.booking.show', $booking)
->with('error', 'Gagal memproses pembayaran: ' . $e->getMessage());
}
}
return view('member.payment.show', [
'booking' => $booking,
'snapToken' => $snapToken,
'clientKey' => config('midtrans.client_key'),
]);
}
public function getSnapToken(Booking $booking): JsonResponse
{
$this->authorize('view', $booking);
if ($booking->payment_status === 'paid') {
return response()->json([
'success' => false,
'message' => 'Booking sudah dibayar',
], 400);
}
try {
$snapToken = $this->midtransService->createSnapToken($booking);
return response()->json([
'success' => true,
'snap_token' => $snapToken,
]);
} catch (Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 500);
}
}
public function finish(Request $request, Booking $booking): View
{
$booking->load(['court.courtType', 'timeSlot', 'member']);
$transactionStatus = $request->get('transaction_status');
$orderId = $request->get('order_id');
$status = 'pending';
$message = 'Menunggu konfirmasi pembayaran';
if ($transactionStatus === 'settlement' || $transactionStatus === 'capture') {
$status = 'success';
$message = 'Pembayaran berhasil! Booking Anda telah dikonfirmasi.';
} elseif ($transactionStatus === 'pending') {
$status = 'pending';
$message = 'Menunggu pembayaran. Silakan selesaikan pembayaran Anda.';
} elseif ($transactionStatus === 'deny' || $transactionStatus === 'cancel' || $transactionStatus === 'expire') {
$status = 'failed';
$message = 'Pembayaran gagal atau dibatalkan.';
}
return view('member.payment.finish', [
'booking' => $booking,
'status' => $status,
'message' => $message,
]);
}
public function checkStatus(Booking $booking): JsonResponse
{
$this->authorize('view', $booking);
$booking->refresh();
return response()->json([
'payment_status' => $booking->payment_status,
'booking_status' => $booking->booking_status,
]);
}
}
Membuat Controller untuk Webhook Notification
Midtrans akan mengirim notification ke server kita setiap kali ada update status pembayaran. Saya buat controller khusus untuk menangani webhook ini:
php artisan make:controller Webhook/MidtransController
Isi file app/Http/Controllers/Webhook/MidtransController.php:
<?php
namespace App\\Http\\Controllers\\Webhook;
use App\\Http\\Controllers\\Controller;
use App\\Services\\MidtransService;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Support\\Facades\\Log;
class MidtransController extends Controller
{
protected MidtransService $midtransService;
public function __construct(MidtransService $midtransService)
{
$this->midtransService = $midtransService;
}
public function handleNotification(Request $request): JsonResponse
{
Log::info('Midtrans Notification Received', $request->all());
try {
$result = $this->midtransService->handleNotification();
Log::info('Midtrans Notification Processed', $result);
return response()->json([
'success' => true,
'message' => $result['message'] ?? 'Notification processed',
]);
} catch (\\Exception $e) {
Log::error('Midtrans Notification Error', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 500);
}
}
}
Menambahkan Route untuk Payment
Saya buka file routes/web.php dan tambahkan route untuk payment:
<?php
use App\\Http\\Controllers\\Member\\DashboardController;
use App\\Http\\Controllers\\Member\\BookingController;
use App\\Http\\Controllers\\Member\\PaymentController;
use App\\Http\\Controllers\\ProfileController;
use Illuminate\\Support\\Facades\\Route;
Route::get('/', function () {
return view('welcome');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
Route::middleware(['auth', 'verified'])->prefix('member')->name('member.')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/booking', [BookingController::class, 'index'])->name('booking.index');
Route::get('/booking/create', [BookingController::class, 'create'])->name('booking.create');
Route::post('/booking', [BookingController::class, 'store'])->name('booking.store');
Route::get('/booking/{booking}', [BookingController::class, 'show'])->name('booking.show');
Route::post('/booking/{booking}/cancel', [BookingController::class, 'cancel'])->name('booking.cancel');
Route::get('/history', [BookingController::class, 'history'])->name('booking.history');
Route::get('/booking/{booking}/payment', [PaymentController::class, 'show'])->name('booking.payment');
Route::get('/booking/{booking}/payment/snap-token', [PaymentController::class, 'getSnapToken'])->name('booking.payment.snap-token');
Route::get('/booking/{booking}/payment/finish', [PaymentController::class, 'finish'])->name('booking.payment.finish');
Route::get('/booking/{booking}/payment/status', [PaymentController::class, 'checkStatus'])->name('booking.payment.status');
});
require __DIR__.'/auth.php';
Kemudian saya buat file route terpisah untuk webhook di routes/api.php:
<?php
use App\\Http\\Controllers\\Webhook\\MidtransController;
use Illuminate\\Support\\Facades\\Route;
Route::post('/webhook/midtrans', [MidtransController::class, 'handleNotification'])
->name('webhook.midtrans');
Mengecualikan Webhook dari CSRF Verification
Webhook dari Midtrans tidak akan menyertakan CSRF token, jadi saya perlu mengecualikan route ini dari CSRF verification. Saya buka file bootstrap/app.php dan tambahkan:
<?php
use Illuminate\\Foundation\\Application;
use Illuminate\\Foundation\\Configuration\\Exceptions;
use Illuminate\\Foundation\\Configuration\\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'check.admin.role' => \\App\\Http\\Middleware\\CheckAdminRole::class,
]);
$middleware->validateCsrfTokens(except: [
'api/webhook/*',
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
Membuat View untuk Halaman Payment
Sekarang saya buat view untuk halaman pembayaran. Pertama, saya buat file resources/views/member/payment/show.blade.php:
<x-member-layout>
<x-slot name="header">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Pembayaran Booking
</h1>
</x-slot>
<div class="max-w-3xl mx-auto">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden">
<!-- Booking Summary -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Ringkasan Booking</h2>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Kode Booking</span>
<span class="font-medium text-gray-900 dark:text-white">{{ $booking->booking_code }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Lapangan</span>
<span class="font-medium text-gray-900 dark:text-white">{{ $booking->court->name }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Tipe</span>
<span class="font-medium text-gray-900 dark:text-white">{{ $booking->court->courtType->name }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Tanggal</span>
<span class="font-medium text-gray-900 dark:text-white">{{ $booking->booking_date->format('l, d F Y') }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Waktu</span>
<span class="font-medium text-gray-900 dark:text-white">{{ $booking->timeSlot->formatted_slot }}</span>
</div>
</div>
</div>
<!-- Price Breakdown -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Rincian Harga</h2>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Harga Sewa</span>
<span class="text-gray-900 dark:text-white">Rp {{ number_format($booking->base_price, 0, ',', '.') }}</span>
</div>
@if($booking->additional_charges > 0)
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Biaya Tambahan</span>
<span class="text-gray-900 dark:text-white">Rp {{ number_format($booking->additional_charges, 0, ',', '.') }}</span>
</div>
@endif
@if($booking->discount > 0)
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Diskon Member</span>
<span class="text-green-600 dark:text-green-400">- Rp {{ number_format($booking->discount, 0, ',', '.') }}</span>
</div>
@endif
<div class="border-t border-gray-200 dark:border-gray-700 pt-3">
<div class="flex justify-between">
<span class="text-lg font-semibold text-gray-900 dark:text-white">Total</span>
<span class="text-lg font-bold text-emerald-600 dark:text-emerald-400">Rp {{ number_format($booking->total_price, 0, ',', '.') }}</span>
</div>
</div>
</div>
</div>
<!-- Payment Button -->
<div class="p-6">
<button id="pay-button" class="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-semibold py-3 px-6 rounded-lg transition duration-200">
Bayar Sekarang
</button>
<p class="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">
Pembayaran diproses secara aman melalui Midtrans
</p>
</div>
</div>
<!-- Back Button -->
<div class="mt-6 text-center">
<a href="{{ route('member.booking.show', $booking) }}" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
← Kembali ke Detail Booking
</a>
</div>
</div>
@push('scripts')
<script src="<https://app.sandbox.midtrans.com/snap/snap.js>" data-client-key="{{ $clientKey }}"></script>
<script>
document.getElementById('pay-button').addEventListener('click', function() {
snap.pay('{{ $snapToken }}', {
onSuccess: function(result) {
console.log('Payment Success:', result);
window.location.href = '{{ route('member.booking.payment.finish', $booking) }}?transaction_status=settlement&order_id={{ $booking->booking_code }}';
},
onPending: function(result) {
console.log('Payment Pending:', result);
window.location.href = '{{ route('member.booking.payment.finish', $booking) }}?transaction_status=pending&order_id={{ $booking->booking_code }}';
},
onError: function(result) {
console.log('Payment Error:', result);
window.location.href = '{{ route('member.booking.payment.finish', $booking) }}?transaction_status=error&order_id={{ $booking->booking_code }}';
},
onClose: function() {
console.log('Payment popup closed');
}
});
});
</script>
@endpush
</x-member-layout>
Kemudian saya buat view untuk halaman finish di resources/views/member/payment/finish.blade.php:
<x-member-layout>
<x-slot name="header">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Status Pembayaran
</h1>
</x-slot>
<div class="max-w-2xl mx-auto">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden text-center p-8">
@if($status === 'success')
<div class="w-20 h-20 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Pembayaran Berhasil!</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">{{ $message }}</p>
@elseif($status === 'pending')
<div class="w-20 h-20 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Menunggu Pembayaran</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">{{ $message }}</p>
@else
<div class="w-20 h-20 bg-red-100 dark:bg-red-900 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Pembayaran Gagal</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">{{ $message }}</p>
@endif
<!-- Booking Info -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 mb-6 text-left">
<h3 class="font-semibold text-gray-900 dark:text-white mb-4">Detail Booking</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Kode Booking</span>
<span class="font-medium text-gray-900 dark:text-white">{{ $booking->booking_code }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Lapangan</span>
<span class="font-medium text-gray-900 dark:text-white">{{ $booking->court->name }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Tanggal</span>
<span class="font-medium text-gray-900 dark:text-white">{{ $booking->booking_date->format('d F Y') }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Waktu</span>
<span class="font-medium text-gray-900 dark:text-white">{{ $booking->timeSlot->formatted_slot }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Total</span>
<span class="font-medium text-emerald-600 dark:text-emerald-400">Rp {{ number_format($booking->total_price, 0, ',', '.') }}</span>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ route('member.booking.show', $booking) }}" class="inline-flex items-center justify-center px-6 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-semibold rounded-lg transition">
Lihat Detail Booking
</a>
<a href="{{ route('member.dashboard') }}" class="inline-flex items-center justify-center px-6 py-3 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-900 dark:text-white font-semibold rounded-lg transition">
Kembali ke Dashboard
</a>
</div>
</div>
</div>
</x-member-layout>
Menambahkan Tombol Bayar di Detail Booking
Saya perlu mengupdate view detail booking untuk menambahkan tombol bayar. Saya buka file resources/views/member/booking/show.blade.php dan tambahkan:
<x-member-layout>
<x-slot name="header">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Detail Booking
</h1>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
@if($booking->booking_status === 'confirmed') bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
@elseif($booking->booking_status === 'pending') bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
@elseif($booking->booking_status === 'completed') bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
@elseif($booking->booking_status === 'cancelled') bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
@else bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200
@endif">
{{ ucfirst($booking->booking_status) }}
</span>
</div>
</x-slot>
<div class="max-w-3xl mx-auto">
<!-- Flash 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>
@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
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden">
<!-- Booking Code -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Kode Booking</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ $booking->booking_code }}</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-500 dark:text-gray-400">Dibuat pada</p>
<p class="text-gray-900 dark:text-white">{{ $booking->created_at->format('d M Y, H:i') }}</p>
</div>
</div>
</div>
<!-- Court Info -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Informasi Lapangan</h3>
<div class="flex items-start space-x-4">
@if($booking->court->image)
<img src="{{ Storage::url($booking->court->image) }}" alt="{{ $booking->court->name }}" class="w-24 h-24 rounded-lg object-cover">
@else
<div class="w-24 h-24 bg-emerald-100 dark:bg-emerald-900 rounded-lg flex items-center justify-center">
<span class="text-3xl">🎾</span>
</div>
@endif
<div>
<h4 class="font-semibold text-gray-900 dark:text-white">{{ $booking->court->name }}</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $booking->court->courtType->name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-300 mt-2">{{ $booking->court->description }}</p>
</div>
</div>
</div>
<!-- Schedule -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Jadwal</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Tanggal</p>
<p class="font-medium text-gray-900 dark:text-white">{{ $booking->booking_date->format('l, d F Y') }}</p>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Waktu</p>
<p class="font-medium text-gray-900 dark:text-white">{{ $booking->timeSlot->formatted_slot }}</p>
</div>
</div>
</div>
<!-- Payment Info -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Informasi Pembayaran</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Harga Sewa</span>
<span class="text-gray-900 dark:text-white">Rp {{ number_format($booking->base_price, 0, ',', '.') }}</span>
</div>
@if($booking->additional_charges > 0)
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Biaya Tambahan</span>
<span class="text-gray-900 dark:text-white">Rp {{ number_format($booking->additional_charges, 0, ',', '.') }}</span>
</div>
@endif
@if($booking->discount > 0)
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Diskon</span>
<span class="text-green-600 dark:text-green-400">- Rp {{ number_format($booking->discount, 0, ',', '.') }}</span>
</div>
@endif
<div class="border-t border-gray-200 dark:border-gray-600 pt-3">
<div class="flex justify-between">
<span class="font-semibold text-gray-900 dark:text-white">Total</span>
<span class="font-bold text-emerald-600 dark:text-emerald-400">Rp {{ number_format($booking->total_price, 0, ',', '.') }}</span>
</div>
</div>
<div class="flex justify-between items-center pt-2">
<span class="text-gray-600 dark:text-gray-400">Status Pembayaran</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
@if($booking->payment_status === 'paid') bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
@elseif($booking->payment_status === 'pending') bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
@else bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
@endif">
{{ ucfirst($booking->payment_status) }}
</span>
</div>
@if($booking->payment_method)
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Metode Pembayaran</span>
<span class="text-gray-900 dark:text-white">{{ ucfirst(str_replace('_', ' ', $booking->payment_method)) }}</span>
</div>
@endif
</div>
</div>
<!-- Notes -->
@if($booking->notes)
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Catatan</h3>
<p class="text-gray-600 dark:text-gray-300">{{ $booking->notes }}</p>
</div>
@endif
<!-- Actions -->
<div class="p-6">
<div class="flex flex-col sm:flex-row gap-4">
@if($booking->payment_status === 'pending' && !in_array($booking->booking_status, ['cancelled', 'completed']))
<a href="{{ route('member.booking.payment', $booking) }}" class="flex-1 inline-flex items-center justify-center px-6 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-semibold rounded-lg transition">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"></path>
</svg>
Bayar Sekarang
</a>
@endif
@if(!in_array($booking->booking_status, ['cancelled', 'completed']) && $booking->booking_date > now())
<form action="{{ route('member.booking.cancel', $booking) }}" method="POST" class="flex-1" onsubmit="return confirm('Apakah Anda yakin ingin membatalkan booking ini?')">
@csrf
<input type="hidden" name="reason" value="Dibatalkan oleh member">
<button type="submit" class="w-full inline-flex items-center justify-center px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Batalkan Booking
</button>
</form>
@endif
</div>
</div>
</div>
<div class="mt-6 text-center">
<a href="{{ route('member.booking.history') }}" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
← Kembali ke Riwayat Booking
</a>
</div>
</div>
</x-member-layout>
Menambahkan Payment Action di Filament Admin
Saya juga ingin admin dan kasir bisa melihat status pembayaran dan memproses pembayaran manual jika diperlukan. Saya tambahkan action di BookingResource untuk melihat detail pembayaran Midtrans:
Tables\\Actions\\Action::make('viewPayment')
->label('Lihat Pembayaran')
->icon('heroicon-o-credit-card')
->color('info')
->visible(fn (Booking $record) =>
$record->transaction_id &&
auth()->user()->can('view_payments')
)
->modalHeading('Detail Pembayaran')
->modalContent(function (Booking $record) {
$midtransService = app(MidtransService::class);
$status = $midtransService->getTransactionStatus($record->booking_code);
return view('filament.modals.payment-detail', [
'booking' => $record,
'transactionStatus' => $status,
]);
}),
Konfigurasi Webhook URL di Midtrans Dashboard
Setelah semua code selesai, saya perlu mengkonfigurasi webhook URL di Midtrans Dashboard. Saya login ke https://dashboard.sandbox.midtrans.com, pergi ke Settings > Configuration, dan isi Payment Notification URL dengan:
<https://domain-aplikasi.com/api/webhook/midtrans>
Untuk testing di localhost, saya bisa menggunakan ngrok untuk membuat tunnel:
ngrok http 8000
Kemudian gunakan URL dari ngrok sebagai webhook URL di Midtrans Dashboard.
Testing Pembayaran
Untuk testing di sandbox, Midtrans menyediakan kartu kredit test:
- Card Number: 4811 1111 1111 1114
- CVV: 123
- Exp Date: Any future date
Untuk testing bank transfer atau e-wallet, Midtrans akan memberikan instruksi pembayaran dummy yang bisa di-complete melalui Midtrans Dashboard.
Sampai di sini saya sudah berhasil mengintegrasikan Midtrans untuk pembayaran online. Member sekarang bisa membayar booking mereka melalui berbagai metode pembayaran, dan status pembayaran akan otomatis terupdate melalui webhook. Di bagian selanjutnya, saya akan menambahkan fitur notifikasi email otomatis untuk menginformasikan member tentang status booking mereka.
Bagian 9: Menambahkan Fitur Notifikasi Email Otomatis
Sekarang saya akan menambahkan fitur notifikasi email otomatis untuk memberikan informasi kepada member tentang status booking mereka. Fitur ini sangat penting untuk meningkatkan pengalaman pengguna karena member akan selalu mendapat update tentang booking mereka tanpa harus login ke aplikasi. Saya akan membuat beberapa jenis email notification: konfirmasi booking baru, pembayaran berhasil, reminder H-1 sebelum jadwal bermain, dan notifikasi pembatalan.
Mengkonfigurasi Mail di Laravel
Pertama, saya pastikan konfigurasi mail di file .env sudah benar. Untuk development, saya akan menggunakan Mailtrap sebagai mail testing service. Tapi saya juga akan menunjukkan konfigurasi untuk Gmail SMTP yang bisa digunakan di production.
Konfigurasi untuk Mailtrap (Development):
MAIL_MAILER=smtp
MAIL_HOST=sandbox.smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=your_mailtrap_username
MAIL_PASSWORD=your_mailtrap_password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"
Konfigurasi untuk Gmail SMTP (Production):
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
[email protected]
MAIL_PASSWORD=your_app_password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"
Untuk Gmail, saya perlu membuat App Password di Google Account settings karena Gmail tidak mengizinkan login langsung dengan password biasa untuk aplikasi third-party.
Membuat Mailable untuk Booking Confirmation
Saya mulai dengan membuat Mailable class untuk email konfirmasi booking baru:
php artisan make:mail BookingConfirmation --markdown=emails.booking.confirmation
Perintah ini akan membuat dua file: class Mailable di app/Mail/BookingConfirmation.php dan template email di resources/views/emails/booking/confirmation.blade.php.
Saya buka file app/Mail/BookingConfirmation.php dan modifikasi:
<?php
namespace App\\Mail;
use App\\Models\\Booking;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;
use Illuminate\\Queue\\SerializesModels;
class BookingConfirmation extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Booking $booking
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Konfirmasi Booking #' . $this->booking->booking_code . ' - ' . config('app.name'),
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.booking.confirmation',
with: [
'booking' => $this->booking,
'member' => $this->booking->member,
'court' => $this->booking->court,
'timeSlot' => $this->booking->timeSlot,
'paymentUrl' => route('member.booking.payment', $this->booking),
],
);
}
public function attachments(): array
{
return [];
}
}
Saya menambahkan implements ShouldQueue agar email dikirim secara asynchronous melalui queue. Ini penting agar proses booking tidak lambat karena menunggu email terkirim.
Sekarang saya buat template emailnya di resources/views/emails/booking/confirmation.blade.php:
<x-mail::message>
# Booking Berhasil Dibuat! 🎾
Halo **{{ $member->name }}**,
Terima kasih telah melakukan booking di **{{ config('app.name') }}**. Berikut adalah detail booking Anda:
<x-mail::panel>
**Kode Booking:** {{ $booking->booking_code }}
**Lapangan:** {{ $court->name }} ({{ $court->courtType->name }})
**Tanggal:** {{ $booking->booking_date->format('l, d F Y') }}
**Waktu:** {{ $timeSlot->formatted_slot }}
**Total Pembayaran:** Rp {{ number_format($booking->total_price, 0, ',', '.') }}
</x-mail::panel>
@if($booking->payment_status === 'pending')
## Segera Selesaikan Pembayaran
Booking Anda belum dibayar. Silakan selesaikan pembayaran dalam waktu **24 jam** untuk mengkonfirmasi reservasi Anda.
<x-mail::button :url="$paymentUrl" color="success">
Bayar Sekarang
</x-mail::button>
*Jika pembayaran tidak dilakukan dalam waktu 24 jam, booking akan otomatis dibatalkan.*
@else
## Pembayaran Diterima ✓
Pembayaran Anda telah kami terima. Booking Anda sudah dikonfirmasi!
@endif
---
**Catatan Penting:**
- Harap datang **15 menit** sebelum jadwal bermain
- Gunakan pakaian dan sepatu olahraga yang sesuai
- Bawa perlengkapan bermain sendiri atau sewa di tempat
Jika ada pertanyaan, silakan hubungi kami di **{{ config('mail.from.address') }}** atau melalui WhatsApp di **081234567890**.
Sampai jumpa di lapangan! 🏆
Salam,<br>
Tim {{ config('app.name') }}
</x-mail::message>
Membuat Mailable untuk Payment Success
Selanjutnya saya buat Mailable untuk notifikasi pembayaran berhasil:
php artisan make:mail PaymentSuccess --markdown=emails.booking.payment-success
Isi file app/Mail/PaymentSuccess.php:
<?php
namespace App\\Mail;
use App\\Models\\Booking;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;
use Illuminate\\Queue\\SerializesModels;
class PaymentSuccess extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Booking $booking
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Pembayaran Berhasil - Booking #' . $this->booking->booking_code,
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.booking.payment-success',
with: [
'booking' => $this->booking,
'member' => $this->booking->member,
'court' => $this->booking->court,
'timeSlot' => $this->booking->timeSlot,
'bookingUrl' => route('member.booking.show', $this->booking),
],
);
}
public function attachments(): array
{
return [];
}
}
Template email di resources/views/emails/booking/payment-success.blade.php:
<x-mail::message>
# Pembayaran Berhasil! ✅
Halo **{{ $member->name }}**,
Pembayaran untuk booking Anda telah berhasil diproses. Berikut adalah detail booking yang sudah dikonfirmasi:
<x-mail::panel>
**Kode Booking:** {{ $booking->booking_code }}
**Lapangan:** {{ $court->name }} ({{ $court->courtType->name }})
**Tanggal:** {{ $booking->booking_date->format('l, d F Y') }}
**Waktu:** {{ $timeSlot->formatted_slot }}
**Status:** ✓ Dikonfirmasi
</x-mail::panel>
## Detail Pembayaran
| Item | Jumlah |
|:-----|-------:|
| Harga Sewa | Rp {{ number_format($booking->base_price, 0, ',', '.') }} |
@if($booking->additional_charges > 0)
| Biaya Tambahan | Rp {{ number_format($booking->additional_charges, 0, ',', '.') }} |
@endif
@if($booking->discount > 0)
| Diskon Member | - Rp {{ number_format($booking->discount, 0, ',', '.') }} |
@endif
| **Total Dibayar** | **Rp {{ number_format($booking->total_price, 0, ',', '.') }}** |
**Metode Pembayaran:** {{ ucfirst(str_replace('_', ' ', $booking->payment_method)) }}
**Waktu Pembayaran:** {{ $booking->paid_at->format('d F Y, H:i') }} WIB
<x-mail::button :url="$bookingUrl">
Lihat Detail Booking
</x-mail::button>
---
**Reminder:**
- Simpan email ini sebagai bukti booking
- Datang **15 menit** sebelum jadwal bermain
- Tunjukkan kode booking kepada petugas
Terima kasih telah menggunakan layanan kami! 🙏
Salam,<br>
Tim {{ config('app.name') }}
</x-mail::message>
Membuat Mailable untuk Booking Reminder
Sekarang saya buat Mailable untuk reminder H-1 sebelum jadwal bermain:
php artisan make:mail BookingReminder --markdown=emails.booking.reminder
Isi file app/Mail/BookingReminder.php:
<?php
namespace App\\Mail;
use App\\Models\\Booking;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;
use Illuminate\\Queue\\SerializesModels;
class BookingReminder extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Booking $booking
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: '⏰ Reminder: Booking Besok - ' . $this->booking->court->name,
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.booking.reminder',
with: [
'booking' => $this->booking,
'member' => $this->booking->member,
'court' => $this->booking->court,
'timeSlot' => $this->booking->timeSlot,
],
);
}
public function attachments(): array
{
return [];
}
}
Template email di resources/views/emails/booking/reminder.blade.php:
<x-mail::message>
# Jangan Lupa! Booking Anda Besok 📅
Halo **{{ $member->name }}**,
Ini adalah pengingat bahwa Anda memiliki jadwal bermain **besok**:
<x-mail::panel>
**Kode Booking:** {{ $booking->booking_code }}
**Lapangan:** {{ $court->name }}
**Tanggal:** {{ $booking->booking_date->format('l, d F Y') }}
**Waktu:** {{ $timeSlot->formatted_slot }}
</x-mail::panel>
## Checklist Sebelum Bermain ✓
- [ ] Pakaian olahraga yang nyaman
- [ ] Sepatu olahraga (wajib untuk lapangan indoor)
- [ ] Raket (atau sewa di tempat)
- [ ] Bola tenis/padel (atau beli di tempat)
- [ ] Handuk kecil
- [ ] Botol minum
## Informasi Venue
**Alamat:** Jl. Olahraga No. 123, Jakarta
**Fasilitas yang tersedia:**
@if($court->facilities)
@foreach($court->facilities as $facility)
- {{ ucfirst(str_replace('_', ' ', $facility)) }}
@endforeach
@endif
---
*Harap datang **15 menit** sebelum jadwal bermain untuk persiapan.*
Jika Anda tidak dapat hadir, silakan batalkan booking melalui aplikasi atau hubungi kami segera.
Sampai jumpa besok! 🎾
Salam,<br>
Tim {{ config('app.name') }}
</x-mail::message>
Membuat Mailable untuk Booking Cancellation
Saya juga buat Mailable untuk notifikasi pembatalan:
php artisan make:mail BookingCancellation --markdown=emails.booking.cancellation
Isi file app/Mail/BookingCancellation.php:
<?php
namespace App\\Mail;
use App\\Models\\Booking;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;
use Illuminate\\Queue\\SerializesModels;
class BookingCancellation extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Booking $booking,
public string $reason = ''
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Booking Dibatalkan - #' . $this->booking->booking_code,
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.booking.cancellation',
with: [
'booking' => $this->booking,
'member' => $this->booking->member,
'court' => $this->booking->court,
'timeSlot' => $this->booking->timeSlot,
'reason' => $this->reason ?: $this->booking->cancellation_reason,
'newBookingUrl' => route('member.booking.create'),
],
);
}
public function attachments(): array
{
return [];
}
}
Template email di resources/views/emails/booking/cancellation.blade.php:
<x-mail::message>
# Booking Dibatalkan
Halo **{{ $member->name }}**,
Booking Anda dengan detail berikut telah dibatalkan:
<x-mail::panel>
**Kode Booking:** {{ $booking->booking_code }}
**Lapangan:** {{ $court->name }} ({{ $court->courtType->name }})
**Tanggal:** {{ $booking->booking_date->format('l, d F Y') }}
**Waktu:** {{ $timeSlot->formatted_slot }}
**Alasan Pembatalan:** {{ $reason ?: 'Tidak disebutkan' }}
**Waktu Pembatalan:** {{ $booking->cancelled_at ? $booking->cancelled_at->format('d F Y, H:i') : now()->format('d F Y, H:i') }} WIB
</x-panel>
@if($booking->payment_status === 'paid')
## Informasi Refund
Pembayaran Anda sebesar **Rp {{ number_format($booking->total_price, 0, ',', '.') }}** akan diproses untuk refund. Dana akan dikembalikan dalam waktu **3-7 hari kerja** ke metode pembayaran yang digunakan.
Jika ada pertanyaan mengenai refund, silakan hubungi kami.
@endif
---
Kami mohon maaf atas ketidaknyamanan ini. Jangan ragu untuk melakukan booking lagi di lain waktu!
<x-mail::button :url="$newBookingUrl">
Booking Lagi
</x-mail::button>
Salam,<br>
Tim {{ config('app.name') }}
</x-mail::message>
Membuat Mailable untuk Payment Reminder
Saya juga buat email reminder untuk booking yang belum dibayar:
php artisan make:mail PaymentReminder --markdown=emails.booking.payment-reminder
Isi file app/Mail/PaymentReminder.php:
<?php
namespace App\\Mail;
use App\\Models\\Booking;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Mail\\Mailable;
use Illuminate\\Mail\\Mailables\\Content;
use Illuminate\\Mail\\Mailables\\Envelope;
use Illuminate\\Queue\\SerializesModels;
class PaymentReminder extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Booking $booking,
public int $hoursRemaining = 24
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: '⚠️ Segera Bayar - Booking #' . $this->booking->booking_code . ' Akan Expired',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.booking.payment-reminder',
with: [
'booking' => $this->booking,
'member' => $this->booking->member,
'court' => $this->booking->court,
'timeSlot' => $this->booking->timeSlot,
'hoursRemaining' => $this->hoursRemaining,
'paymentUrl' => route('member.booking.payment', $this->booking),
],
);
}
public function attachments(): array
{
return [];
}
}
Template email di resources/views/emails/booking/payment-reminder.blade.php:
<x-mail::message>
# ⚠️ Booking Anda Menunggu Pembayaran
Halo **{{ $member->name }}**,
Booking Anda belum dibayar dan akan **expired dalam {{ $hoursRemaining }} jam**.
<x-mail::panel>
**Kode Booking:** {{ $booking->booking_code }}
**Lapangan:** {{ $court->name }}
**Tanggal:** {{ $booking->booking_date->format('l, d F Y') }}
**Waktu:** {{ $timeSlot->formatted_slot }}
**Total:** Rp {{ number_format($booking->total_price, 0, ',', '.') }}
</x-mail::panel>
Segera selesaikan pembayaran untuk mengkonfirmasi reservasi Anda.
<x-mail::button :url="$paymentUrl" color="success">
Bayar Sekarang
</x-mail::button>
---
*Jika pembayaran tidak dilakukan sebelum waktu expired, booking akan otomatis dibatalkan dan slot waktu akan tersedia untuk member lain.*
Salam,<br>
Tim {{ config('app.name') }}
</x-mail::message>
Membuat Event dan Listener untuk Mengirim Email
Sekarang saya akan membuat Event dan Listener agar email terkirim otomatis saat ada perubahan status booking. Pertama, saya buat Event untuk booking created:
php artisan make:event BookingCreated
Isi file app/Events/BookingCreated.php:
<?php
namespace App\\Events;
use App\\Models\\Booking;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class BookingCreated
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Booking $booking
) {}
}
Kemudian buat Event untuk payment success:
php artisan make:event PaymentReceived
Isi file app/Events/PaymentReceived.php:
<?php
namespace App\\Events;
use App\\Models\\Booking;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class PaymentReceived
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Booking $booking
) {}
}
Buat Event untuk booking cancelled:
php artisan make:event BookingCancelled
Isi file app/Events/BookingCancelled.php:
<?php
namespace App\\Events;
use App\\Models\\Booking;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class BookingCancelled
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Booking $booking,
public string $reason = ''
) {}
}
Membuat Listener untuk Mengirim Email
Sekarang saya buat Listener yang akan mengirim email saat Event di-dispatch:
php artisan make:listener SendBookingConfirmationEmail --event=BookingCreated
Isi file app/Listeners/SendBookingConfirmationEmail.php:
<?php
namespace App\\Listeners;
use App\\Events\\BookingCreated;
use App\\Mail\\BookingConfirmation;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Support\\Facades\\Mail;
class SendBookingConfirmationEmail implements ShouldQueue
{
public function handle(BookingCreated $event): void
{
$booking = $event->booking;
$booking->load(['member', 'court.courtType', 'timeSlot']);
Mail::to($booking->member->email)
->send(new BookingConfirmation($booking));
}
}
Buat Listener untuk payment success:
php artisan make:listener SendPaymentSuccessEmail --event=PaymentReceived
Isi file app/Listeners/SendPaymentSuccessEmail.php:
<?php
namespace App\\Listeners;
use App\\Events\\PaymentReceived;
use App\\Mail\\PaymentSuccess;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Support\\Facades\\Mail;
class SendPaymentSuccessEmail implements ShouldQueue
{
public function handle(PaymentReceived $event): void
{
$booking = $event->booking;
$booking->load(['member', 'court.courtType', 'timeSlot']);
Mail::to($booking->member->email)
->send(new PaymentSuccess($booking));
}
}
Buat Listener untuk cancellation:
php artisan make:listener SendBookingCancellationEmail --event=BookingCancelled
Isi file app/Listeners/SendBookingCancellationEmail.php:
<?php
namespace App\\Listeners;
use App\\Events\\BookingCancelled;
use App\\Mail\\BookingCancellation;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Support\\Facades\\Mail;
class SendBookingCancellationEmail implements ShouldQueue
{
public function handle(BookingCancelled $event): void
{
$booking = $event->booking;
$booking->load(['member', 'court.courtType', 'timeSlot']);
Mail::to($booking->member->email)
->send(new BookingCancellation($booking, $event->reason));
}
}
Mendaftarkan Event dan Listener
Saya perlu mendaftarkan Event dan Listener di app/Providers/AppServiceProvider.php:
<?php
namespace App\\Providers;
use App\\Events\\BookingCancelled;
use App\\Events\\BookingCreated;
use App\\Events\\PaymentReceived;
use App\\Listeners\\SendBookingCancellationEmail;
use App\\Listeners\\SendBookingConfirmationEmail;
use App\\Listeners\\SendPaymentSuccessEmail;
use App\\Models\\Booking;
use App\\Policies\\BookingPolicy;
use Illuminate\\Support\\Facades\\Event;
use Illuminate\\Support\\Facades\\Gate;
use Illuminate\\Support\\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Gate::policy(Booking::class, BookingPolicy::class);
Event::listen(
BookingCreated::class,
SendBookingConfirmationEmail::class
);
Event::listen(
PaymentReceived::class,
SendPaymentSuccessEmail::class
);
Event::listen(
BookingCancelled::class,
SendBookingCancellationEmail::class
);
}
}
Memanggil Event di Model dan Service
Sekarang saya perlu memanggil Event di tempat yang tepat. Pertama, saya update model Booking untuk dispatch event saat booking dibuat:
<?php
namespace App\\Models;
use App\\Events\\BookingCancelled;
use App\\Events\\BookingCreated;
use App\\Events\\PaymentReceived;
use Illuminate\\Database\\Eloquent\\Model;
// ... other imports
class Booking extends Model
{
// ... existing code
protected static function boot()
{
parent::boot();
static::creating(function ($booking) {
if (empty($booking->booking_code)) {
$booking->booking_code = self::generateBookingCode();
}
});
static::created(function ($booking) {
event(new BookingCreated($booking));
});
}
public function markAsPaid($paymentMethod, $transactionId = null): void
{
$this->update([
'payment_status' => 'paid',
'payment_method' => $paymentMethod,
'transaction_id' => $transactionId,
'paid_at' => now(),
'booking_status' => 'confirmed',
]);
$this->member->updateStatistics();
event(new PaymentReceived($this));
}
public function cancel($reason = null): void
{
$this->update([
'booking_status' => 'cancelled',
'cancellation_reason' => $reason,
'cancelled_at' => now(),
]);
event(new BookingCancelled($this, $reason ?? ''));
}
// ... rest of the code
}
Membuat Scheduled Command untuk Reminder
Saya perlu membuat scheduled command untuk mengirim reminder H-1 dan payment reminder. Pertama, saya buat command untuk booking reminder:
php artisan make:command SendBookingReminders
Isi file app/Console/Commands/SendBookingReminders.php:
<?php
namespace App\\Console\\Commands;
use App\\Mail\\BookingReminder;
use App\\Models\\Booking;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Mail;
class SendBookingReminders extends Command
{
protected $signature = 'bookings:send-reminders';
protected $description = 'Send reminder emails for bookings scheduled for tomorrow';
public function handle(): int
{
$tomorrow = now()->addDay()->toDateString();
$bookings = Booking::where('booking_date', $tomorrow)
->where('booking_status', 'confirmed')
->where('payment_status', 'paid')
->with(['member', 'court.courtType', 'timeSlot'])
->get();
$count = 0;
foreach ($bookings as $booking) {
Mail::to($booking->member->email)
->send(new BookingReminder($booking));
$count++;
$this->info("Reminder sent to: {$booking->member->email} for booking {$booking->booking_code}");
}
$this->info("Total reminders sent: {$count}");
return Command::SUCCESS;
}
}
Buat command untuk payment reminder:
php artisan make:command SendPaymentReminders
Isi file app/Console/Commands/SendPaymentReminders.php:
<?php
namespace App\\Console\\Commands;
use App\\Mail\\PaymentReminder;
use App\\Models\\Booking;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Mail;
class SendPaymentReminders extends Command
{
protected $signature = 'bookings:send-payment-reminders';
protected $description = 'Send reminder emails for unpaid bookings';
public function handle(): int
{
$bookings = Booking::where('payment_status', 'pending')
->where('booking_status', '!=', 'cancelled')
->where('created_at', '>', now()->subHours(24))
->where('created_at', '<', now()->subHours(12))
->with(['member', 'court', 'timeSlot'])
->get();
$count = 0;
foreach ($bookings as $booking) {
$hoursRemaining = 24 - now()->diffInHours($booking->created_at);
Mail::to($booking->member->email)
->send(new PaymentReminder($booking, max(1, $hoursRemaining)));
$count++;
$this->info("Payment reminder sent to: {$booking->member->email} for booking {$booking->booking_code}");
}
$this->info("Total payment reminders sent: {$count}");
return Command::SUCCESS;
}
}
Buat command untuk expire unpaid bookings:
php artisan make:command ExpireUnpaidBookings
Isi file app/Console/Commands/ExpireUnpaidBookings.php:
<?php
namespace App\\Console\\Commands;
use App\\Models\\Booking;
use Illuminate\\Console\\Command;
class ExpireUnpaidBookings extends Command
{
protected $signature = 'bookings:expire-unpaid';
protected $description = 'Cancel bookings that have not been paid within 24 hours';
public function handle(): int
{
$expiredBookings = Booking::where('payment_status', 'pending')
->where('booking_status', '!=', 'cancelled')
->where('created_at', '<', now()->subHours(24))
->get();
$count = 0;
foreach ($expiredBookings as $booking) {
$booking->update([
'payment_status' => 'expired',
'booking_status' => 'cancelled',
'cancellation_reason' => 'Pembayaran tidak dilakukan dalam waktu 24 jam',
'cancelled_at' => now(),
]);
$count++;
$this->info("Booking expired: {$booking->booking_code}");
}
$this->info("Total bookings expired: {$count}");
return Command::SUCCESS;
}
}
Menjadwalkan Command
Saya daftarkan scheduled commands di routes/console.php:
<?php
use Illuminate\\Support\\Facades\\Schedule;
Schedule::command('bookings:send-reminders')
->dailyAt('09:00')
->withoutOverlapping()
->onOneServer();
Schedule::command('bookings:send-payment-reminders')
->everyFourHours()
->withoutOverlapping()
->onOneServer();
Schedule::command('bookings:expire-unpaid')
->hourly()
->withoutOverlapping()
->onOneServer();
Mengkonfigurasi Queue
Agar email dikirim secara asynchronous, saya perlu menjalankan queue worker. Di file .env, pastikan queue connection sudah diset:
QUEUE_CONNECTION=database
Kemudian jalankan migration untuk tabel jobs jika belum:
php artisan queue:table
php artisan migrate
Untuk menjalankan queue worker:
php artisan queue:work
Di production, saya akan menggunakan Supervisor untuk menjaga queue worker tetap berjalan.
Kustomisasi Template Email
Saya juga bisa mengkustomisasi tampilan default email Laravel. Pertama, publish vendor views:
php artisan vendor:publish --tag=laravel-mail
Ini akan membuat folder resources/views/vendor/mail yang berisi template email yang bisa dikustomisasi. Saya bisa mengubah warna, logo, dan styling sesuai branding aplikasi.
Testing Email
Untuk testing, saya bisa menggunakan beberapa metode:
Menggunakan Mailtrap - semua email akan masuk ke inbox Mailtrap dan bisa dilihat tampilannya.
Menggunakan log driver untuk development:
MAIL_MAILER=log
Email akan ditulis ke file storage/logs/laravel.log.
Atau menggunakan Artisan tinker untuk mengirim email manual:
php artisan tinker
$booking = App\\Models\\Booking::first();
Mail::to('[email protected]')->send(new App\\Mail\\BookingConfirmation($booking));
Sampai di sini saya sudah berhasil mengimplementasikan sistem notifikasi email yang lengkap. Member akan menerima email saat booking dibuat, saat pembayaran berhasil, reminder H-1 sebelum bermain, dan notifikasi jika booking dibatalkan. Di bagian selanjutnya, saya akan menuliskan penutup dan saran untuk melanjutkan pembelajaran.
Bagian 10: Penutup dan Saran untuk Pengembangan Karir
Selamat! Kita sudah berhasil menyelesaikan tutorial lengkap membangun website booking lapangan tenis dan padel menggunakan Laravel 12, Filament, Spatie Permission, dan Midtrans. Saya sangat senang bisa berbagi pengalaman dan pengetahuan ini dengan teman-teman semua. Mari kita recap apa saja yang sudah kita pelajari dan bangun bersama.
Rangkuman Pembelajaran
Sepanjang tutorial ini, saya sudah memandu teman-teman untuk membangun aplikasi yang cukup kompleks dan siap digunakan untuk kebutuhan bisnis nyata. Berikut adalah rangkuman dari setiap bagian yang sudah kita kerjakan:
Bagian 1 - Pendahuluan: Saya menjelaskan pentingnya memiliki halaman admin yang terstruktur untuk mengelola bisnis booking lapangan olahraga, termasuk pengelolaan lapangan, jadwal, member, transaksi, dan laporan.
Bagian 2 - Setup Projek Laravel: Saya membuat projek Laravel baru menggunakan Composer dan mengkonfigurasi koneksi database MySQL melalui file .env.
Bagian 3 - Migration dan Model: Saya membuat struktur database yang solid dengan 5 tabel utama yaitu court_types, courts, time_slots, members, dan bookings. Setiap model dilengkapi dengan fillable, casting, dan relationship yang tepat.
Bagian 4 - Instalasi Filament: Saya menginstall dan mengkonfigurasi Filament sebagai admin panel, membuat akun admin, dan membangun dashboard dengan widget statistik dan chart.
Bagian 5 - Filament Resources: Saya membuat resource CRUD lengkap untuk setiap tabel dengan form yang interaktif, tabel yang informatif, filter yang berguna, dan action yang sesuai kebutuhan bisnis.
Bagian 6 - Authentication: Saya mengimplementasikan sistem authentication menggunakan Laravel Breeze untuk member area, lengkap dengan halaman register, login, dan dashboard member.
Bagian 7 - Role dan Permission: Saya menggunakan Spatie Laravel Permission untuk mengatur hak akses berbeda untuk admin, manager, staff operasional, kasir, dan member.
Bagian 8 - Integrasi Midtrans: Saya mengintegrasikan payment gateway Midtrans untuk memproses pembayaran online dengan berbagai metode pembayaran seperti transfer bank, e-wallet, dan QRIS.
Bagian 9 - Email Notification: Saya membuat sistem notifikasi email otomatis untuk konfirmasi booking, pembayaran berhasil, reminder, dan pembatalan menggunakan Event-Driven Architecture.
Fitur-Fitur yang Sudah Dibangun
Aplikasi yang sudah kita bangun memiliki fitur-fitur berikut:
Untuk Admin dan Staff:
- Dashboard dengan statistik real-time
- Manajemen tipe lapangan dan lapangan
- Manajemen slot waktu dengan prime time pricing
- Manajemen member dengan berbagai level membership
- Manajemen booking dengan berbagai status
- Manajemen user dan role
- Proses pembayaran manual
Untuk Member:
- Registrasi dan login
- Dashboard personal dengan statistik
- Booking lapangan dengan pilihan tanggal dan waktu
- Pembayaran online melalui Midtrans
- Riwayat booking
- Pembatalan booking
Sistem Otomatis:
- Auto-generate booking code
- Kalkulasi harga otomatis (weekday/weekend, prime time, diskon member)
- Email konfirmasi booking
- Email pembayaran berhasil
- Email reminder H-1
- Email pembatalan
- Auto-expire booking yang tidak dibayar
Potensi Pengembangan Lebih Lanjut
Aplikasi ini masih bisa dikembangkan lebih lanjut dengan fitur-fitur tambahan seperti:
- Booking Recurring: Fitur untuk member yang ingin booking secara rutin setiap minggu
- Waiting List: Sistem antrian jika slot yang diinginkan sudah penuh
- Review dan Rating: Member bisa memberikan review setelah bermain
- Promo dan Voucher: Sistem diskon dengan kode voucher
- Membership Package: Paket membership dengan benefit khusus
- Mobile App: Aplikasi mobile menggunakan Flutter atau React Native
- WhatsApp Notification: Integrasi dengan WhatsApp Business API
- Calendar View: Tampilan kalender untuk melihat ketersediaan lapangan
- Multi-venue: Dukungan untuk mengelola beberapa venue sekaligus
- Reporting Advanced: Laporan yang lebih detail dengan export PDF/Excel
Tingkatkan Skill Anda Bersama BuildWithAngga
Setelah menyelesaikan tutorial ini, mungkin teman-teman bertanya-tanya, "Bagaimana cara saya meningkatkan skill lebih jauh lagi? Bagaimana caranya agar saya bisa mendapatkan pekerjaan atau project sebagai web developer?"
Saya sangat merekomendasikan teman-teman untuk belajar bersama mentor di BuildWithAngga. Platform ini menyediakan pembelajaran yang terstruktur dan praktis untuk membantu teman-teman menjadi web developer profesional.
Mengapa Belajar di BuildWithAngga?
1. Portfolio Berkualitas
Di BuildWithAngga, teman-teman tidak hanya belajar teori, tapi langsung membangun project nyata yang bisa dijadikan portfolio. Portfolio yang berkualitas adalah kunci untuk mendapatkan pekerjaan atau client. Setiap kelas dirancang agar output-nya bisa langsung dipamerkan ke calon employer atau client.
2. Akses Selamanya
Sekali mendaftar, teman-teman mendapat akses selamanya ke materi pembelajaran. Tidak ada batasan waktu, jadi bisa belajar sesuai pace masing-masing. Materi juga terus diupdate mengikuti perkembangan teknologi terbaru.
3. Bimbingan Langsung dari Mentor
Berbeda dengan belajar sendiri dari tutorial random di internet, di BuildWithAngga teman-teman dibimbing langsung oleh mentor yang berpengalaman di industri. Bisa tanya jawab langsung jika ada kesulitan atau butuh advice untuk karir.
4. Kurikulum yang Terstruktur
Materi disusun secara sistematis dari dasar hingga advanced. Tidak perlu bingung harus mulai dari mana atau belajar apa selanjutnya. Tinggal ikuti learning path yang sudah disediakan.
5. Project-Based Learning
Setiap kelas fokus pada membangun project nyata, bukan hanya teori. Ini membuat proses belajar lebih menyenangkan dan langsung applicable di dunia kerja.
6. Komunitas yang Supportive
Bergabung dengan komunitas developer lain yang sedang belajar. Bisa saling sharing, networking, dan bahkan berkolaborasi untuk project bersama.
7. Sertifikat Completion
Setelah menyelesaikan kelas, teman-teman mendapat sertifikat yang bisa ditambahkan ke LinkedIn atau CV untuk meningkatkan kredibilitas.
8. Update Materi Berkala
Teknologi web development terus berkembang. Materi di BuildWithAngga selalu diupdate agar tetap relevan dengan kebutuhan industri saat ini.
9. Studi Kasus Industri Nyata
Project yang dibangun di kelas menggunakan studi kasus dari industri nyata, sehingga teman-teman siap menghadapi tantangan di dunia kerja.
10. Persiapan Kerja Remote
BuildWithAngga juga mempersiapkan teman-teman untuk bekerja secara remote, baik sebagai freelancer maupun sebagai karyawan di perusahaan luar negeri. Skill ini sangat valuable di era digital saat ini.
11. Harga Terjangkau
Dibanding bootcamp lain yang harganya puluhan juta, BuildWithAngga menawarkan harga yang jauh lebih terjangkau dengan kualitas materi yang tidak kalah.
12. Support Karir
Selain pembelajaran teknis, BuildWithAngga juga memberikan guidance untuk persiapan karir seperti cara membuat CV yang menarik, tips interview, dan cara mencari project freelance.
Kelas yang Relevan di BuildWithAngga
Untuk melanjutkan pembelajaran dari tutorial ini, saya sarankan teman-teman untuk mengambil kelas-kelas berikut di BuildWithAngga:
- Laravel Web Development - Mendalami Laravel lebih jauh dengan best practices
- Filament Admin Panel - Mempelajari fitur-fitur advanced Filament
- Payment Gateway Integration - Integrasi dengan berbagai payment gateway
- API Development - Membangun RESTful API untuk mobile app
- Vue.js atau React - Frontend framework untuk membangun UI yang lebih interaktif
- DevOps dan Deployment - Cara deploy aplikasi ke production server
Kata Penutup
Tutorial ini adalah langkah awal dalam perjalanan teman-teman menjadi web developer profesional. Masih banyak hal yang perlu dipelajari, tapi jangan khawatir - setiap expert dulunya adalah pemula.
Yang terpenting adalah konsistensi. Teruslah belajar, teruslah coding, dan teruslah membangun project. Semakin banyak project yang teman-teman bangun, semakin terasah skill-nya.
Jangan ragu untuk bergabung dengan komunitas developer, baik online maupun offline. Networking adalah bagian penting dari karir di bidang teknologi. Siapa tahu, dari komunitas itulah teman-teman mendapat kesempatan kerja atau project pertama.
Terakhir, ingatlah bahwa kesalahan adalah bagian dari proses belajar. Jangan takut untuk mencoba hal baru dan membuat kesalahan. Dari situlah kita belajar dan berkembang.
Saya berharap tutorial ini bermanfaat untuk teman-teman semua. Jika ada pertanyaan atau ingin berdiskusi lebih lanjut, jangan ragu untuk bergabung dengan komunitas BuildWithAngga.
Selamat belajar dan semoga sukses dalam perjalanan menjadi web developer profesional! 🚀
"The only way to learn a new programming language is by writing programs in it." - Dennis Ritchie
Sumber Daya Tambahan:
- Dokumentasi Laravel: https://laravel.com/docs
- Dokumentasi Filament: https://filamentphp.com/docs
- Dokumentasi Spatie Permission: https://spatie.be/docs/laravel-permission
- Dokumentasi Midtrans: https://docs.midtrans.com
- BuildWithAngga: https://buildwithangga.com
Sampai jumpa di tutorial selanjutnya! 👋