Tutorial Laravel 12, Midtrans Payment, Filament 3, Spatie Role: Bikin Web Sewa Kantor

Halo teman-teman! Kali ini kita bakal belajar bikin website yang beneran bisa dipakai dan bahkan dijual lagi. Bayangin aja, setelah tutorial ini selesai, kalian udah punya skill buat bikin website sewa kantor yang modern dan profesional. Ini bukan cuma tutorial biasa yang abis baca langsung lupa, tapi kita bakal bikin proyek nyata dari nol sampai bisa dipake!

Kenapa sih pilih studi kasus website sewa kantor? Soalnya website kayak gini tuh kompleks tapi ga terlalu rumit buat dipelajari. Ada sistem user management, booking system, payment handling, dashboard admin, dan masih banyak lagi. Sempurna banget buat ngasah skill development kita.

Teknologi yang Bakal Kita Pelajari

Di tutorial ini, kita bakal pake teknologi-teknologi keren yang lagi hits di dunia web development:

Laravel 12 - Framework PHP yang paling populer dan powerful. Laravel 12 ini versi terbaru yang udah dilengkapi dengan fitur-fitur canggih kayak improved performance, better security, dan developer experience yang makin mantap. Dengan Laravel, kita bisa bikin aplikasi web dengan cepat tanpa perlu ribet ngoding dari nol.

Filament 3 - Ini dia admin panel yang bikin hidup developer jadi gampang banget! Filament 3 adalah CRUD generator dan admin panel builder yang bisa bikin dashboard admin dalam hitungan menit. Ga perlu lagi ngoding form-form admin yang boring, semua udah auto-generated dengan tampilan yang kece.

Spatie Laravel Permission - Package legend buat ngatur role dan permission di Laravel. Dengan ini, kita bisa bikin sistem user yang kompleks dengan berbagai level akses. Misalnya admin bisa akses semua fitur, operator cuma bisa lihat booking, customer cuma bisa booking aja.

Target Pembelajaran dan Skill yang Akan Dikuasai

Setelah ngikutin tutorial ini sampai habis, kalian bakal menguasai:

Full-Stack Development Skills: Mulai dari setup database, bikin API backend, sampai frontend yang responsive. Lengkap banget kan?

Modern Laravel Development: Kalian bakal paham gimana cara pake Laravel 12 dengan best practices. Mulai dari migration, model relationships, controller logic, sampai blade templating.

Admin Panel Mastery: Dengan Filament 3, kalian bakal jadi expert dalam bikin dashboard admin yang powerful. Ga cuma CRUD biasa, tapi juga statistik, charts, dan fitur-fitur canggih lainnya.

User Management System: Pakai Spatie Permission, kalian bakal bisa bikin sistem user yang aman dan terstruktur. Tau cara ngatur siapa bisa ngapain di website.

Database Design: Belajar bikin struktur database yang efisien dan scalable. Ini skill fundamental yang bakal kepake terus di project lain.

Frontend Integration: Meskipun fokus ke backend, kita juga bakal belajar cara integrate dengan frontend pake Tailwind CSS yang lagi trending.

Kenapa Pilih Website Sewa Kantor?

Mungkin ada yang nanya, "Kok pilih website sewa kantor sih? Kan udah banyak yang bikin tutorial e-commerce atau blog?" Nah, ini alasannya:

Real Business Case: Website sewa kantor itu beneran ada pasarnya. Apalagi sekarang era remote work dan co-working space lagi booming. Jadi project yang kita bikin bisa beneran dipake!

Kompleksitas yang Pas: Ga terlalu simple kayak todo app, tapi juga ga terlalu rumit kayak sistem perbankan. Perfect buat belajar konsep-konsep intermediate sampai advanced.

Multiple User Types: Di sini kita bakal belajar handle berbagai jenis user dengan permission yang beda-beda. Customer, admin, operator, masing-masing punya akses yang berbeda.

Payment Integration: Kita bakal belajar cara handle sistem pembayaran, mulai dari booking sampai konfirmasi bayar. Skill ini penting banget di era digital sekarang.

Business Intelligence: Dashboard admin ga cuma CRUD doang, tapi juga ada laporan pendapatan, statistik okupansi, dan insights bisnis lainnya.

Scalable Architecture: Project ini dirancang biar bisa dikembangkan lebih lanjut. Mau nambah fitur chat, notification, atau integrasi payment gateway, semuanya udah siap.

Fitur Wajib Website Sewa Kantor: Apa Aja Sih yang Harus Ada?

Nah, sebelum mulai ngoding, kita perlu tau dulu fitur apa aja yang mau kita bikin. Ini penting banget biar development kita terarah dan ga bingung di tengah jalan. Website sewa kantor yang baik tuh harus punya fitur yang lengkap, baik buat customer maupun admin.

Kita bakal bagi fitur-fiturnya berdasarkan jenis usernya ya. Ada fitur buat customer (yang mau sewa kantor) dan ada fitur buat admin/operator (yang manage website). Mari kita bahas satu-satu!

Fitur untuk Customer: Pengalaman User yang Memorable

Homepage dengan Daftar Kantor dan Filter Pencarian

Homepage itu kayak etalase toko, harus menarik dan informatif. Di sini customer bakal langsung liat daftar kantor-kantor yang tersedia dengan foto yang eye-catching. Yang paling penting, ada sistem filter yang powerful biar customer bisa nyari kantor sesuai kebutuhan.

Filter yang kita sediain bakal lengkap banget: berdasarkan lokasi (kota, area), harga per hari/bulan, kapasitas ruangan, fasilitas yang tersedia (WiFi, AC, proyektor, dll), dan rating dari customer lain. Bayangin customer bisa filter "Kantor di Jakarta Selatan, budget maksimal 500rb per hari, kapasitas minimal 10 orang, harus ada proyektor". Dalam sekejap, website kita bakal nampilin kantor-kantor yang sesuai.

Tampilannya juga harus responsive dan user-friendly. Customer bisa switch antara tampilan grid (kayak Instagram) atau list view (kayak Google search results). Plus ada sort option berdasarkan harga termurah, rating tertinggi, atau jarak terdekat.

Detail Kantor dengan Galeri Foto dan Informasi Lengkap

Kalau customer udah nemuin kantor yang menarik, mereka pasti mau tau detail lengkapnya. Halaman detail kantor ini harus comprehensive banget. Ada galeri foto yang kece dengan lightbox view, jadi customer bisa liat foto-foto kantor dengan jelas.

Informasi yang ditampilin meliputi deskripsi lengkap kantor, alamat detail dengan Google Maps integration, daftar fasilitas yang tersedia, kapasitas maksimal, harga sewa per jam/hari/bulan, jam operasional, dan aturan-aturan khusus. Jangan lupa ada bagian review dan rating dari customer sebelumnya, ini penting banget buat build trust.

Yang bikin keren lagi, kita bakal tambahin fitur "Virtual Tour" kalau ada, availability calendar yang real-time, dan rekomendasi kantor serupa. Customer juga bisa save kantor ke wishlist buat di-compare nanti.

Sistem Booking/Reservasi Online

Ini adalah jantung dari website kita! Sistem booking harus smooth dan intuitif. Customer pilih tanggal dan jam, sistem langsung cek availability real-time. Kalau available, customer bisa langsung book dengan flow yang simple.

Booking form bakal include pilihan durasi sewa (per jam, harian, mingguan, bulanan), jumlah peserta, add-on services kalau ada (catering, equipment rental, dll), dan informasi kontak. Ada juga opsi booking recurring buat customer yang butuh kantor rutin.

Yang penting, ada sistem konfirmasi yang jelas. Customer dapet booking ID, email konfirmasi, dan bisa track status booking mereka. Plus ada opsi reschedule atau cancel dengan policy yang jelas.

Form Checkout dengan Upload Bukti Pembayaran

Proses checkout harus se-simple mungkin tapi tetap aman. Customer review booking details, pilih metode pembayaran (transfer bank, e-wallet, atau credit card kalau udah integrate payment gateway), dan konfirmasi pesanan.

Khusus buat transfer bank, customer bisa upload bukti pembayaran langsung di website. Sistemnya auto-detect format file yang supported (JPG, PNG, PDF) dan ada preview sebelum upload. Setelah upload, admin bisa langsung verify pembayaran dari dashboard.

Ada juga invoice generator yang auto-create PDF invoice yang professional. Customer bisa download invoice buat keperluan reimbursement kantor mereka.

Profil User dan Riwayat Booking

Customer punya dashboard pribadi yang bersih dan informatif. Di sini mereka bisa edit profil, ganti password, dan yang paling penting: liat riwayat booking mereka.

Riwayat booking nunjukin semua transaksi, mulai dari yang upcoming, ongoing, completed, sampai yang cancelled. Ada detail lengkap tiap booking, status pembayaran, dan opsi buat kasih review setelah pake kantor.

Customer juga bisa re-book kantor yang pernah mereka pake sebelumnya dengan sekali klik. Plus ada fitur favorite list buat save kantor-kantor yang mereka suka.

Fitur untuk Admin/Operator: Control Center yang Powerful

Dashboard Admin dengan Statistik

Dashboard admin itu mission control center-nya website kita. Harus informatif dan actionable. Admin langsung bisa liat overview bisnis: total revenue hari ini/bulan ini, jumlah booking active, occupancy rate, dan trending kantor.

Ada charts yang interactive buat nunjukin tren booking, revenue growth, customer demographics, dan performance tiap kantor. Semua data real-time dan bisa di-export ke Excel buat reporting ke management.

Dashboard juga nunjukin quick actions yang sering dibutuhin: approve pending bookings, verify payments, respond ke customer inquiries, dan alerts buat hal-hal urgent.

Manajemen Data Kantor (CRUD)

Admin bisa manage semua data kantor dengan interface yang user-friendly. Add kantor baru dengan form yang comprehensive: info basic, upload multiple photos, set pricing rules, define availability schedule, dan configure facilities.

Edit kantor juga gampang dengan preview changes sebelum save. Admin bisa set kantor sebagai featured, temporarily unavailable, atau under maintenance. Ada bulk actions buat update harga atau availability multiple kantor sekaligus.

Yang keren, ada integration dengan Google Maps API buat auto-complete address dan show exact location. Plus image optimization yang auto-resize dan compress foto biar website tetep cepet.

Manajemen Booking dan Pembayaran

Ini salah satu fitur yang paling critical. Admin punya full control atas semua booking: approve/reject booking requests, verify payment proof, confirm booking completion, dan handle disputes kalau ada.

Ada booking calendar view yang nunjukin schedule semua kantor dalam satu tampilan. Admin bisa drag-drop buat reschedule booking, set blocking dates, dan manage overbooking situations.

Payment management juga comprehensive: track pembayaran yang pending, verify transfer receipts, generate payment reminders, dan reconcile dengan bank statements. Semua transaksi ter-record dengan audit trail yang lengkap.

Manajemen User dan Role

Dengan Spatie Permission, admin bisa manage user dengan granular control. Ada different roles: Super Admin (full access), Admin (manage bookings dan kantor), Operator (view-only), dan Customer (frontend access).

Admin bisa create/edit user accounts, assign roles, suspend accounts yang bermasalah, dan view user activity logs. Ada juga user analytics: most active customers, customer lifetime value, dan behavior patterns.

System juga support team management buat bisnis yang punya multiple admin. Ada approval workflow buat actions yang sensitive dan activity logging buat accountability.

Laporan Pendapatan dan Okupansi

Business intelligence adalah kunci sukses bisnis modern. Admin bisa generate berbagai jenis laporan: daily/monthly/yearly revenue reports, occupancy rates per kantor, customer acquisition trends, dan profitability analysis.

Laporan bisa di-customize berdasarkan date range, specific kantor, customer segments, atau booking types. Semua laporan exportable ke PDF atau Excel dengan formatting yang professional.

Ada juga predictive analytics yang simple: forecasting revenue berdasarkan trend historis, identify peak seasons, dan recommend pricing strategies. Dashboard nunjukin KPIs yang penting buat decision making.

Database dan Tabel yang Dibutuhkan: Fondasi Kuat untuk Aplikasi Keren

Oke teman-teman, sekarang kita masuk ke bagian yang super penting tapi sering diabaikan sama developer pemula: database design! Ini adalah fondasi dari seluruh aplikasi kita. Kalau database design-nya berantakan, aplikasi bakal jadi lambat, susah di-maintain, dan prone to bugs.

Gue bakal jelasin struktur database yang optimal buat website sewa kantor kita. Setiap tabel udah dipikirin dengan matang biar scalable dan efficient. Mari kita breakdown satu per satu!

Struktur Database yang Akan Kita Gunakan

Database kita bakal punya 7 tabel utama yang saling terhubung dengan relasi yang logis. Setiap tabel punya purpose yang jelas dan field-field yang optimized buat kebutuhan bisnis kita.

Tabel Users: The Heart of User Management

Tabel users ini adalah pusat dari sistem authentication dan authorization kita. Di sini kita store semua data user, baik customer, admin, maupun operator.

Field-field yang bakal ada:

  • id (bigint, primary key, auto increment): Unique identifier buat setiap user
  • name (varchar 255): Nama lengkap user, wajib diisi
  • email (varchar 255, unique): Email address yang juga dipake buat login
  • email_verified_at (timestamp, nullable): Kapan email ter-verify, penting buat security
  • password (varchar 255): Hashed password pake bcrypt Laravel
  • phone (varchar 20, nullable): Nomor telepon buat keperluan booking confirmation
  • avatar (varchar 255, nullable): Path ke foto profile user
  • is_active (boolean, default true): Status aktif/non-aktif user
  • last_login_at (timestamp, nullable): Track kapan terakhir kali login
  • remember_token (varchar 100, nullable): Token buat "remember me" functionality
  • created_at dan updated_at (timestamp): Laravel timestamps

Yang bikin special, tabel ini bakal integrate dengan Spatie Permission buat handle roles dan permissions. Jadi satu user bisa punya multiple roles atau custom permissions.

Tabel Roles dan Permissions: Spatie Power!

Ini adalah tabel-tabel yang auto-generated sama Spatie Laravel Permission package. Kita ga perlu bikin manual, tapi penting buat dipahami strukturnya.

Tabel roles:

  • id (bigint, primary key)
  • name (varchar 255): Nama role kayak 'admin', 'customer', 'operator'
  • guard_name (varchar 255): Guard yang dipake, biasanya 'web'
  • created_at dan updated_at

Tabel permissions:

  • id (bigint, primary key)
  • name (varchar 255): Nama permission kayak 'view_bookings', 'edit_offices'
  • guard_name (varchar 255)
  • created_at dan updated_at

Ada juga pivot tables: model_has_roles, model_has_permissions, dan role_has_permissions yang ngatur relasi many-to-many antara users, roles, dan permissions.

Tabel Offices: Tempat Menyimpan Data Kantor

Ini adalah tabel yang store semua informasi kantor yang available buat disewa. Struktur tabelnya dirancang buat support semua fitur yang udah kita bahas sebelumnya.

Field-field lengkapnya:

  • id (bigint, primary key): Unique identifier kantor
  • name (varchar 255): Nama kantor, harus catchy dan descriptive
  • slug (varchar 255, unique): URL-friendly version dari nama, buat SEO
  • description (text): Deskripsi lengkap kantor, bisa pake HTML formatting
  • address (text): Alamat lengkap kantor
  • city (varchar 100): Kota lokasi kantor, buat filtering
  • state (varchar 100): Provinsi lokasi kantor
  • postal_code (varchar 10): Kode pos buat keperluan mapping
  • latitude dan longitude (decimal 10,8): Koordinat GPS buat Google Maps
  • capacity (integer): Maksimal jumlah orang yang bisa accommodate
  • area_size (decimal 8,2): Luas area dalam meter persegi
  • hourly_price (decimal 10,2): Harga sewa per jam
  • daily_price (decimal 10,2): Harga sewa per hari
  • monthly_price (decimal 10,2): Harga sewa per bulan
  • is_featured (boolean, default false): Apakah kantor featured di homepage
  • is_available (boolean, default true): Status availability kantor
  • images (json): Array of image paths, flexible buat multiple photos
  • opening_hours (json): Jam operasional per hari, stored as JSON object
  • contact_person (varchar 255): PIC kantor buat keperluan koordinasi
  • contact_phone (varchar 20): Nomor telepon PIC
  • rating (decimal 3,2, default 0): Average rating dari customer reviews
  • total_reviews (integer, default 0): Total jumlah reviews
  • created_at dan updated_at

JSON fields dipake buat data yang struktur kompleks kayak images array dan opening hours, lebih flexible daripada bikin tabel terpisah.

Tabel Office_Facilities: Fasilitas yang Tersedia

Biar customer bisa filter berdasarkan fasilitas, kita perlu tabel terpisah buat manage facilities. Ini many-to-many relationship dengan tabel offices.

Field-field yang ada:

  • id (bigint, primary key)
  • name (varchar 100): Nama fasilitas kayak 'WiFi', 'AC', 'Proyektor'
  • icon (varchar 100): Icon class buat display di frontend
  • description (text, nullable): Deskripsi detail fasilitas
  • is_active (boolean, default true): Status aktif fasilitas
  • created_at dan updated_at

Terus ada pivot table office_facility yang connect offices dengan facilities:

  • office_id (foreign key ke offices table)
  • facility_id (foreign key ke office_facilities table)
  • is_available (boolean): Apakah fasilitas currently available
  • additional_info (text, nullable): Info tambahan spesifik buat office-facility combo

Tabel Bookings: Jantung Sistem Reservasi

Ini adalah tabel paling critical di aplikasi kita. Semua transaksi booking tersimpan di sini dengan detail yang lengkap.

Structure yang comprehensive:

  • id (bigint, primary key)
  • booking_code (varchar 20, unique): Kode booking yang user-friendly
  • user_id (foreign key): Siapa yang booking
  • office_id (foreign key): Kantor mana yang di-booking
  • start_date (date): Tanggal mulai sewa
  • end_date (date): Tanggal selesai sewa
  • start_time (time, nullable): Jam mulai buat hourly booking
  • end_time (time, nullable): Jam selesai buat hourly booking
  • duration_type (enum): 'hourly', 'daily', 'monthly'
  • total_days (integer): Total hari sewa, calculated field
  • participant_count (integer): Jumlah peserta yang bakal pake kantor
  • purpose (varchar 255): Tujuan booking kayak 'meeting', 'workshop', 'training'
  • special_requests (text, nullable): Request khusus dari customer
  • subtotal (decimal 10,2): Harga sebelum tax dan fees
  • tax_amount (decimal 10,2): Jumlah pajak
  • total_amount (decimal 10,2): Total yang harus dibayar
  • status (enum): 'pending', 'confirmed', 'completed', 'cancelled'
  • notes (text, nullable): Catatan internal dari admin
  • cancelled_at (timestamp, nullable): Kapan booking di-cancel
  • cancellation_reason (text, nullable): Alasan cancellation
  • created_at dan updated_at

Status enum yang jelas bikin flow booking gampang di-track dan di-manage.

Tabel Payments: Track Semua Transaksi

Setiap booking punya record payment yang terpisah. Ini penting buat auditing dan financial reporting.

Field-field detailnya:

  • id (bigint, primary key)
  • booking_id (foreign key): Link ke booking terkait
  • payment_method (enum): 'bank_transfer', 'credit_card', 'e_wallet'
  • amount (decimal 10,2): Jumlah yang dibayar
  • payment_proof (varchar 255, nullable): Path ke file bukti transfer
  • payment_date (timestamp, nullable): Kapan payment dilakukan
  • verified_at (timestamp, nullable): Kapan admin verify payment
  • verified_by (foreign key ke users, nullable): Admin yang verify
  • status (enum): 'pending', 'verified', 'failed', 'refunded'
  • reference_number (varchar 100, nullable): Reference number dari bank/payment gateway
  • notes (text, nullable): Catatan dari admin atau customer
  • created_at dan updated_at

Dengan tabel terpisah, kita bisa track multiple payments buat satu booking (misalnya partial payment atau refund).

Tabel Reviews: Customer Feedback System

Customer satisfaction adalah key, jadi kita perlu sistem review yang robust buat collect feedback dan build trust.

Structure yang user-friendly:

  • id (bigint, primary key)
  • booking_id (foreign key): Review terkait booking mana
  • user_id (foreign key): Siapa yang kasih review
  • office_id (foreign key): Kantor yang di-review
  • rating (integer): Rating 1-5 stars
  • title (varchar 255, nullable): Judul review
  • comment (text): Isi review dari customer
  • pros (text, nullable): Hal-hal positif yang disukai
  • cons (text, nullable): Hal-hal yang perlu diperbaiki
  • is_anonymous (boolean, default false): Apakah review anonymous
  • is_approved (boolean, default true): Status approval dari admin
  • helpful_count (integer, default 0): Berapa orang yang nganggap review helpful
  • created_at dan updated_at

Review system yang detail kayak gini bikin customer lain lebih confident buat booking.

Relasi Antar Tabel dan Foreign Keys

Database design yang baik itu harus punya relasi yang logical dan efficient. Mari kita breakdown relasi-relasi penting:

User Relationships:

  • User hasMany Bookings (satu user bisa punya banyak booking)
  • User hasMany Reviews (satu user bisa kasih banyak review)
  • User hasMany Payments melalui Bookings (user punya payment records)

Office Relationships:

  • Office hasMany Bookings (satu kantor bisa di-booking berkali-kali)
  • Office hasMany Reviews (satu kantor bisa punya banyak review)
  • Office belongsToMany Facilities (many-to-many relationship)

Booking Relationships:

  • Booking belongsTo User (setiap booking punya owner)
  • Booking belongsTo Office (setiap booking terkait satu kantor)
  • Booking hasOne Payment (setiap booking punya payment record)
  • Booking hasOne Review (booking bisa di-review customer)

Complex Relationships:

  • Payment belongsTo Booking dan belongsTo User (melalui booking)
  • Review belongsTo User, belongsTo Office, dan belongsTo Booking

Field-Field Penting pada Setiap Tabel

Setiap field yang kita design punya purpose yang jelas buat support business logic aplikasi kita.

Indexing Strategy:

  • Primary keys udah auto-indexed
  • Foreign keys perlu di-index buat query performance
  • Fields yang sering di-filter kayak city, status, is_available perlu index
  • Composite index buat query kompleks kayak filtering by city + price range

Data Types yang Optimal:

  • Decimal buat currency supaya precise calculation
  • Enum buat status fields supaya data consistent
  • JSON buat flexible data kayak images array dan opening hours
  • Timestamp buat audit trail dan reporting

Validation Rules:

  • Email harus unique dan valid format
  • Phone numbers pake international format
  • Coordinates harus valid lat/lng range
  • Prices ga boleh negative
  • Dates harus logical (end_date >= start_date)

Security Considerations:

  • Password selalu di-hash pake bcrypt
  • Sensitive data kayak payment info encrypted
  • Audit trail buat track who changed what when
  • Soft deletes buat important records kayak bookings

Dengan database structure yang solid kayak gini, aplikasi kita bakal performant, scalable, dan mudah di-maintain. Setiap query bakal efficient, relationship queries bakal cepet, dan kita bisa generate reports dengan mudah.

Next, kita bakal mulai implement semua design ini dengan install Laravel dan setup project dari scratch!

Instalasi Laravel dan Setup Proyek: Mari Mulai Coding!

Oke guys, sekarang saatnya kita mulai hands-on! Di bagian ini kita bakal setup environment development dari nol sampai siap coding. Gue bakal guide kalian step by step, jadi pastikan ikutin setiap langkahnya dengan teliti ya.

Sebelum mulai, pastikan di komputer kalian udah ada PHP 8.2 atau yang lebih baru, Composer, dan database MySQL/PostgreSQL. Kalau belum ada, install dulu ya!

Install Laravel 12 Terbaru Menggunakan Composer

Laravel 12 adalah versi terbaru yang packed dengan fitur-fitur keren dan performance improvements. Kita bakal install via Composer yang merupakan dependency manager buat PHP.

Buka terminal atau command prompt, terus jalankan command ini:

composer create-project laravel/laravel office-rental-app

Command ini bakal download Laravel versi terbaru dan setup project baru dengan nama "office-rental-app". Proses download biasanya butuh beberapa menit tergantung koneksi internet kalian.

Setelah selesai, masuk ke folder project:

cd office-rental-app

Coba jalankan Laravel development server buat mastiin semuanya berjalan dengan baik:

php artisan serve

Buka browser dan akses http://localhost:8000. Kalau muncul welcome page Laravel yang kece, berarti instalasi sukses!

Pro tip: Kalau kalian developer yang sering bikin project Laravel, install Laravel Installer globally supaya lebih cepet:

composer global require laravel/installer
laravel new office-rental-app

Membuat Proyek Baru Laravel dengan Command Line

Setelah project ter-create, ada beberapa hal yang perlu kita setup supaya project ready buat development yang serious.

Pertama, generate application key yang unique buat project kita:

php artisan key:generate

Key ini penting banget buat encryption dan session security. Laravel auto-generate random key yang secure.

Terus kita setup storage link buat handle file uploads nanti:

php artisan storage:link

Command ini bikin symbolic link dari public/storage ke storage/app/public, jadi file yang di-upload customer bisa diakses dari web.

Install Filament 3 Package ke Dalam Proyek

Sekarang kita install Filament 3, admin panel yang bakal bikin hidup kita jadi gampang banget! Filament 3 adalah upgrade major dari versi sebelumnya dengan UI yang lebih modern dan performa yang lebih kenceng.

Install Filament via Composer:

composer require filament/filament:"^3.0"

Setelah package ter-install, jalankan command install buat setup Filament:

php artisan filament:install --panels

Command ini bakal:

  • Publish Filament config files
  • Setup service providers yang dibutuhin
  • Create admin panel structure
  • Install dependencies yang diperluin

Filament juga butuh beberapa packages tambahan buat functionality yang optimal:

composer require filament/spatie-laravel-media-library-plugin
composer require filament/spatie-laravel-settings-plugin

Packages ini ngebantu handle media uploads dan settings management di admin panel.

Install Spatie Laravel Permission Package

Spatie Laravel Permission adalah package legend buat handle authentication dan authorization. Package ini udah mature banget dan dipake sama ribuan project Laravel.

Install package-nya:

composer require spatie/laravel-permission

Publish migration files buat role dan permission tables:

php artisan vendor:publish --provider="Spatie\\Permission\\PermissionServiceProvider"

Command ini bakal create migration files buat tables: roles, permissions, model_has_permissions, model_has_roles, dan role_has_permissions.

Kita juga perlu clear cache setelah install:

php artisan optimize:clear

Mengatur File .env untuk Konfigurasi Database

File .env adalah tempat kita store environment variables, termasuk database configuration. Ini adalah file yang sensitive jadi jangan pernah di-commit ke Git ya!

Buka file .env di root project dan update database configuration:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=office_rental_db
DB_USERNAME=root
DB_PASSWORD=your_password_here

Ganti your_password_here dengan password MySQL kalian. Kalau pake XAMPP atau MAMP, biasanya password-nya kosong.

Buat database baru dengan nama office_rental_db:

Kalau pake MySQL command line:

CREATE DATABASE office_rental_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Atau kalau pake phpMyAdmin, tinggal create database baru dengan nama yang sama.

Buat yang pake PostgreSQL, config-nya sedikit beda:

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=office_rental_db
DB_USERNAME=postgres
DB_PASSWORD=your_postgres_password

Setup juga mail configuration buat email notifications:

MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
[email protected]
MAIL_PASSWORD=your_app_password
MAIL_ENCRYPTION=tls
[email protected]
MAIL_FROM_NAME="${APP_NAME}"

Dan jangan lupa setup app configuration:

APP_NAME="Office Rental"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_TIMEZONE=Asia/Jakarta

Menjalankan Migration Awal dan Testing Koneksi Database

Sekarang kita test koneksi database dan jalankan migration default Laravel plus Spatie permissions.

Test koneksi database dulu:

php artisan migrate:status

Kalau muncul list migrations tanpa error, berarti koneksi database udah oke.

Jalankan migration buat create tables default Laravel:

php artisan migrate

Command ini bakal create tables: users, password_reset_tokens, failed_jobs, dan tables buat Spatie permissions.

Kalau ada error "Access denied" atau "Connection refused", double-check database credentials di file .env dan pastikan MySQL/PostgreSQL service udah running.

Create user admin pertama buat akses Filament:

php artisan make:filament-user

Command ini bakal prompt kalian buat input:

User ini bakal jadi super admin yang bisa akses semua fitur di dashboard.

Test akses ke Filament admin panel:

php artisan serve

Buka browser dan akses http://localhost:8000/admin. Login pake credentials yang tadi kalian buat.

Kalau berhasil masuk ke dashboard Filament yang kece, congratulations! Setup dasar udah complete.

Optimasi dan Konfigurasi Tambahan

Supaya development experience lebih smooth, ada beberapa konfigurasi tambahan yang recommended:

Install Laravel Debugbar buat debugging:

composer require barryvdh/laravel-debugbar --dev

Install Laravel IDE Helper buat better code completion:

composer require barryvdh/laravel-ide-helper --dev
php artisan ide-helper:generate

Setup queue worker buat background jobs:

QUEUE_CONNECTION=database

Terus jalankan migration buat jobs table:

php artisan queue:table
php artisan migrate

Update cache configuration buat better performance:

CACHE_DRIVER=file
SESSION_DRIVER=file

Kalau mau lebih advanced, bisa pake Redis:

CACHE_DRIVER=redis
SESSION_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

Verifikasi Setup dan Troubleshooting

Sebelum lanjut ke coding, mari kita verify bahwa semua udah setup dengan benar:

Check Laravel version:

php artisan --version

List semua artisan commands yang available:

php artisan list

Check installed packages:

composer show

Test database connection dengan Tinker:

php artisan tinker
>>> DB::connection()->getPdo();
>>> User::count();

Kalau semua command di atas jalan tanpa error, berarti setup udah perfect!

Common Issues dan Solutions:

  • "Class not found" error: Jalankan composer dump-autoload
  • "No application encryption key": Jalankan php artisan key:generate
  • Database connection error: Check credentials di .env dan service database
  • Permission denied di storage: Jalankan chmod -R 775 storage bootstrap/cache
  • Filament 404 error: Pastiin route cache clear dengan php artisan route:clear

Dengan setup yang solid kayak gini, kita udah siap buat mulai bikin aplikasi yang awesome! Next, kita bakal mulai create migrations, models, dan controllers buat implement database design yang udah kita rancang sebelumnya.

Pembuatan Migration, Model, dan Controller: Foundation Aplikasi Kita

Sekarang kita masuk ke tahap yang exciting banget! Kita bakal create migration files, models, dan controllers yang jadi backbone aplikasi kita. Ini adalah implementasi dari database design yang udah kita rancang di bagian sebelumnya.

Gue bakal guide kalian step by step buat bikin setiap component dengan struktur yang clean dan best practices Laravel. Let's get our hands dirty with some real coding!

Membuat File Migration untuk Semua Tabel yang Dibutuhkan

Migration adalah version control buat database kita. Dengan migration, tim developer bisa sync database schema dengan mudah dan track perubahan struktur database.

Mari kita mulai bikin migration files satu per satu. Ingat, urutan pembuatan migration penting karena ada foreign key dependencies.

Pertama, kita create migration buat tabel office_facilities:

php artisan make:migration create_office_facilities_table

Terus buat migration buat tabel offices:

php artisan make:migration create_offices_table

Lanjut dengan tabel bookings:

php artisan make:migration create_bookings_table

Migration buat tabel payments:

php artisan make:migration create_payments_table

Migration buat tabel reviews:

php artisan make:migration create_reviews_table

Dan yang terakhir, pivot table buat many-to-many relationship:

php artisan make:migration create_office_facility_pivot_table

Kita juga perlu modify tabel users yang udah ada buat nambah field tambahan:

php artisan make:migration add_additional_fields_to_users_table

Struktur dan Field pada Setiap Migration File

Sekarang kita implementasikan struktur database yang udah kita design. Setiap migration file bakal berisi schema definition yang detail dan optimal.

Migration: office_facilities_table

Buka file database/migrations/xxxx_create_office_facilities_table.php:

<?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('office_facilities', function (Blueprint $table) {
            $table->id();
            $table->string('name', 100);
            $table->string('icon', 100)->nullable();
            $table->text('description')->nullable();
            $table->boolean('is_active')->default(true);
            $table->timestamps();

            // Indexes for better performance
            $table->index('is_active');
            $table->index('name');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('office_facilities');
    }
};

Migration: offices_table

File database/migrations/xxxx_create_offices_table.php:

<?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('offices', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description');
            $table->text('address');
            $table->string('city', 100);
            $table->string('state', 100);
            $table->string('postal_code', 10);
            $table->decimal('latitude', 10, 8)->nullable();
            $table->decimal('longitude', 11, 8)->nullable();
            $table->integer('capacity');
            $table->decimal('area_size', 8, 2)->nullable();
            $table->decimal('hourly_price', 10, 2);
            $table->decimal('daily_price', 10, 2);
            $table->decimal('monthly_price', 10, 2);
            $table->boolean('is_featured')->default(false);
            $table->boolean('is_available')->default(true);
            $table->json('images')->nullable();
            $table->json('opening_hours')->nullable();
            $table->string('contact_person')->nullable();
            $table->string('contact_phone', 20)->nullable();
            $table->decimal('rating', 3, 2)->default(0);
            $table->integer('total_reviews')->default(0);
            $table->timestamps();

            // Indexes for filtering and searching
            $table->index(['city', 'is_available']);
            $table->index(['is_featured', 'is_available']);
            $table->index('rating');
            $table->fullText(['name', 'description', 'address']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('offices');
    }
};

Migration: bookings_table

File database/migrations/xxxx_create_bookings_table.php:

<?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', 20)->unique();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->foreignId('office_id')->constrained()->onDelete('cascade');
            $table->date('start_date');
            $table->date('end_date');
            $table->time('start_time')->nullable();
            $table->time('end_time')->nullable();
            $table->enum('duration_type', ['hourly', 'daily', 'monthly']);
            $table->integer('total_days');
            $table->integer('participant_count');
            $table->string('purpose')->nullable();
            $table->text('special_requests')->nullable();
            $table->decimal('subtotal', 10, 2);
            $table->decimal('tax_amount', 10, 2)->default(0);
            $table->decimal('total_amount', 10, 2);
            $table->enum('status', ['pending', 'confirmed', 'completed', 'cancelled'])->default('pending');
            $table->text('notes')->nullable();
            $table->timestamp('cancelled_at')->nullable();
            $table->text('cancellation_reason')->nullable();
            $table->timestamps();

            // Indexes for performance
            $table->index(['user_id', 'status']);
            $table->index(['office_id', 'start_date', 'end_date']);
            $table->index('status');
            $table->index('booking_code');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('bookings');
    }
};

Migration: payments_table

File database/migrations/xxxx_create_payments_table.php:

<?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('payments', function (Blueprint $table) {
            $table->id();
            $table->foreignId('booking_id')->constrained()->onDelete('cascade');
            $table->enum('payment_method', ['bank_transfer', 'credit_card', 'e_wallet']);
            $table->decimal('amount', 10, 2);
            $table->string('payment_proof')->nullable();
            $table->timestamp('payment_date')->nullable();
            $table->timestamp('verified_at')->nullable();
            $table->foreignId('verified_by')->nullable()->constrained('users')->onDelete('set null');
            $table->enum('status', ['pending', 'verified', 'failed', 'refunded'])->default('pending');
            $table->string('reference_number', 100)->nullable();
            $table->text('notes')->nullable();
            $table->timestamps();

            // Indexes
            $table->index(['booking_id', 'status']);
            $table->index('status');
            $table->index('payment_date');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('payments');
    }
};

Migration: reviews_table

File database/migrations/xxxx_create_reviews_table.php:

<?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('reviews', function (Blueprint $table) {
            $table->id();
            $table->foreignId('booking_id')->constrained()->onDelete('cascade');
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->foreignId('office_id')->constrained()->onDelete('cascade');
            $table->integer('rating')->unsigned();
            $table->string('title')->nullable();
            $table->text('comment');
            $table->text('pros')->nullable();
            $table->text('cons')->nullable();
            $table->boolean('is_anonymous')->default(false);
            $table->boolean('is_approved')->default(true);
            $table->integer('helpful_count')->default(0);
            $table->timestamps();

            // Constraints
            $table->check('rating >= 1 AND rating <= 5');

            // Indexes
            $table->index(['office_id', 'is_approved']);
            $table->index(['user_id', 'created_at']);
            $table->index('rating');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('reviews');
    }
};

Migration: office_facility_pivot_table

File database/migrations/xxxx_create_office_facility_pivot_table.php:

<?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('office_facility', function (Blueprint $table) {
            $table->id();
            $table->foreignId('office_id')->constrained()->onDelete('cascade');
            $table->foreignId('office_facility_id')->constrained()->onDelete('cascade');
            $table->boolean('is_available')->default(true);
            $table->text('additional_info')->nullable();
            $table->timestamps();

            // Unique constraint to prevent duplicates
            $table->unique(['office_id', 'office_facility_id']);

            // Indexes
            $table->index(['office_id', 'is_available']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('office_facility');
    }
};

Migration: add_additional_fields_to_users_table

File database/migrations/xxxx_add_additional_fields_to_users_table.php:

<?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('users', function (Blueprint $table) {
            $table->string('phone', 20)->nullable()->after('email');
            $table->string('avatar')->nullable()->after('phone');
            $table->boolean('is_active')->default(true)->after('avatar');
            $table->timestamp('last_login_at')->nullable()->after('is_active');

            // Index for phone lookup
            $table->index('phone');
            $table->index('is_active');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn(['phone', 'avatar', 'is_active', 'last_login_at']);
        });
    }
};

Membuat Model Eloquent untuk Setiap Tabel

Model adalah representation dari tabel database dalam bentuk class PHP. Laravel pake Eloquent ORM yang sangat powerful buat interact dengan database.

Mari kita create model buat setiap tabel:

php artisan make:model OfficeFacility
php artisan make:model Office
php artisan make:model Booking
php artisan make:model Payment
php artisan make:model Review

Model User udah ada by default, jadi kita tinggal modify aja sesuai kebutuhan.

Membuat Controller untuk Logic Bisnis Aplikasi

Controller adalah tempat kita taruh business logic aplikasi. Kita bakal bikin controller buat handle requests dari frontend.

Create controllers buat setiap entity:

php artisan make:controller Api/OfficeController
php artisan make:controller Api/BookingController
php artisan make:controller Api/PaymentController
php artisan make:controller Api/ReviewController
php artisan make:controller Web/HomeController
php artisan make:controller Web/OfficeController
php artisan make:controller Web/BookingController

Kita bagi controller jadi dua kategori:

  • Api controllers: Buat handle API requests (JSON responses)
  • Web controllers: Buat handle web requests (Blade views)

Struktur ini bikin aplikasi kita lebih organized dan scalable.

Menjalankan Migration dan Seeding Data Dummy

Sekarang kita jalankan semua migration yang udah kita buat:

php artisan migrate

Kalau ada error, biasanya karena foreign key constraints atau typo di migration files. Check kembali struktur migration dan pastikan urutan pembuatan sudah benar.

Buat seeding data dummy supaya kita punya data buat testing:

php artisan make:seeder OfficeFacilitySeeder
php artisan make:seeder OfficeSeeder
php artisan make:seeder UserSeeder

Contoh OfficeFacilitySeeder:

File database/seeders/OfficeFacilitySeeder.php:

<?php

namespace Database\\Seeders;

use App\\Models\\OfficeFacility;
use Illuminate\\Database\\Seeder;

class OfficeFacilitySeeder extends Seeder
{
    public function run(): void
    {
        $facilities = [
            ['name' => 'WiFi', 'icon' => 'wifi', 'description' => 'High-speed internet connection'],
            ['name' => 'AC', 'icon' => 'snowflake', 'description' => 'Air conditioning'],
            ['name' => 'Projector', 'icon' => 'tv', 'description' => 'HD projector for presentations'],
            ['name' => 'Whiteboard', 'icon' => 'edit', 'description' => 'Large whiteboard'],
            ['name' => 'Sound System', 'icon' => 'volume-2', 'description' => 'Professional sound system'],
            ['name' => 'Parking', 'icon' => 'car', 'description' => 'Free parking space'],
            ['name' => 'Coffee/Tea', 'icon' => 'coffee', 'description' => 'Complimentary beverages'],
            ['name' => 'Kitchen', 'icon' => 'utensils', 'description' => 'Pantry area'],
        ];

        foreach ($facilities as $facility) {
            OfficeFacility::create($facility);
        }
    }
}

Update DatabaseSeeder buat jalanin semua seeders:

<?php

namespace Database\\Seeders;

use Illuminate\\Database\\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            OfficeFacilitySeeder::class,
            UserSeeder::class,
            OfficeSeeder::class,
        ]);
    }
}

Jalankan seeder:

php artisan db:seed

Verification dan Testing

Setelah migration dan seeding selesai, mari kita verify bahwa semuanya berjalan dengan benar:

Check tabel yang ter-create:

php artisan tinker
>>> Schema::hasTable('offices');
>>> Schema::hasTable('bookings');
>>> Schema::hasTable('payments');

Check data yang ter-seed:

>>> App\\Models\\OfficeFacility::count();
>>> App\\Models\\User::count();

Test relationship (ini bakal kita implement di bagian selanjutnya):

>>> $user = App\\Models\\User::first();
>>> $user->bookings; // harusnya return empty collection

Kalau semua command di atas jalan tanpa error, berarti migration dan model setup udah perfect!

Pro Tips:

  • Selalu backup database sebelum jalanin migration di production
  • Gunakan php artisan migrate:rollback kalau ada error
  • Test migration di environment development dulu sebelum deploy
  • Gunakan php artisan migrate:fresh --seed buat reset dan re-seed database
  • Monitor query performance dengan Laravel Debugbar

Dengan foundation yang solid kayak gini, kita udah siap buat implement model relationships dan business logic yang complex. Next step, kita bakal configure models dengan fillable properties, relationships, dan Eloquent features yang advanced!

Konfigurasi Model dan ORM: Bikin Model yang Powerful

Sekarang kita masuk ke bagian yang super penting: configure models supaya bisa handle business logic dengan optimal! Di sini kita bakal setup fillable properties, relationships, accessors, mutators, dan fitur-fitur Eloquent yang advanced.

Model yang well-configured itu adalah kunci dari aplikasi Laravel yang performant dan maintainable. Let's dive deep into each model!

Mengatur Fillable Properties pada Setiap Model

Fillable properties menentukan field mana aja yang boleh di-mass assign. Ini penting banget buat security, mencegah mass assignment vulnerabilities.

Model User (app/Models/User.php)

Update model User yang udah ada:

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Notifications\\Notifiable;
use Spatie\\Permission\\Traits\\HasRoles;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
use Carbon\\Carbon;

class User extends Authenticatable
{
    use HasFactory, Notifiable, HasRoles;

    protected $fillable = [
        'name',
        'email',
        'password',
        'phone',
        'avatar',
        'is_active',
        'last_login_at',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'last_login_at' => 'datetime',
        'is_active' => 'boolean',
        'password' => 'hashed',
    ];

    // Accessor untuk format nama yang proper
    protected function name(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => ucwords(strtolower($value)),
            set: fn (string $value) => strtolower($value),
        );
    }

    // Accessor untuk avatar URL
    protected function avatarUrl(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->avatar
                ? asset('storage/' . $this->avatar)
                : '<https://ui-avatars.com/api/?name=>' . urlencode($this->name) . '&color=7F9CF5&background=EBF4FF'
        );
    }

    // Scope untuk active users
    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }

    // Update last login timestamp
    public function updateLastLogin()
    {
        $this->update(['last_login_at' => now()]);
    }
}

Model OfficeFacility (app/Models/OfficeFacility.php)

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Builder;

class OfficeFacility extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'icon',
        'description',
        'is_active',
    ];

    protected $casts = [
        'is_active' => 'boolean',
    ];

    // Scope untuk facilities yang active
    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }

    // Scope untuk search berdasarkan nama
    public function scopeSearch(Builder $query, string $term): Builder
    {
        return $query->where('name', 'like', "%{$term}%")
                    ->orWhere('description', 'like', "%{$term}%");
    }
}

Model Office (app/Models/Office.php)

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Str;

class Office extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'slug',
        'description',
        'address',
        'city',
        'state',
        'postal_code',
        'latitude',
        'longitude',
        'capacity',
        'area_size',
        'hourly_price',
        'daily_price',
        'monthly_price',
        'is_featured',
        'is_available',
        'images',
        'opening_hours',
        'contact_person',
        'contact_phone',
        'rating',
        'total_reviews',
    ];

    protected $casts = [
        'is_featured' => 'boolean',
        'is_available' => 'boolean',
        'images' => 'array',
        'opening_hours' => 'array',
        'latitude' => 'decimal:8',
        'longitude' => 'decimal:8',
        'hourly_price' => 'decimal:2',
        'daily_price' => 'decimal:2',
        'monthly_price' => 'decimal:2',
        'rating' => 'decimal:2',
        'area_size' => 'decimal:2',
    ];

    // Auto-generate slug ketika create/update
    protected static function boot()
    {
        parent::boot();

        static::creating(function ($office) {
            if (empty($office->slug)) {
                $office->slug = Str::slug($office->name);
            }
        });

        static::updating(function ($office) {
            if ($office->isDirty('name')) {
                $office->slug = Str::slug($office->name);
            }
        });
    }

    // Accessor untuk image URLs
    protected function imageUrls(): Attribute
    {
        return Attribute::make(
            get: function () {
                if (!$this->images) return [];

                return collect($this->images)->map(function ($image) {
                    return asset('storage/' . $image);
                })->toArray();
            }
        );
    }

    // Accessor untuk main image
    protected function mainImage(): Attribute
    {
        return Attribute::make(
            get: function () {
                $images = $this->image_urls;
                return !empty($images) ? $images[0] : asset('images/office-placeholder.jpg');
            }
        );
    }

    // Accessor untuk formatted price
    protected function formattedHourlyPrice(): Attribute
    {
        return Attribute::make(
            get: fn () => 'Rp ' . number_format($this->hourly_price, 0, ',', '.')
        );
    }

    protected function formattedDailyPrice(): Attribute
    {
        return Attribute::make(
            get: fn () => 'Rp ' . number_format($this->daily_price, 0, ',', '.')
        );
    }

    protected function formattedMonthlyPrice(): Attribute
    {
        return Attribute::make(
            get: fn () => 'Rp ' . number_format($this->monthly_price, 0, ',', '.')
        );
    }

    // Scopes untuk filtering
    public function scopeAvailable(Builder $query): Builder
    {
        return $query->where('is_available', true);
    }

    public function scopeFeatured(Builder $query): Builder
    {
        return $query->where('is_featured', true);
    }

    public function scopeInCity(Builder $query, string $city): Builder
    {
        return $query->where('city', 'like', "%{$city}%");
    }

    public function scopeWithCapacity(Builder $query, int $minCapacity): Builder
    {
        return $query->where('capacity', '>=', $minCapacity);
    }

    public function scopePriceRange(Builder $query, float $minPrice, float $maxPrice, string $type = 'daily'): Builder
    {
        $column = $type . '_price';
        return $query->whereBetween($column, [$minPrice, $maxPrice]);
    }

    public function scopeHighRated(Builder $query, float $minRating = 4.0): Builder
    {
        return $query->where('rating', '>=', $minRating);
    }

    // Method untuk update rating
    public function updateRating()
    {
        $stats = $this->reviews()
            ->where('is_approved', true)
            ->selectRaw('AVG(rating) as avg_rating, COUNT(*) as total_reviews')
            ->first();

        $this->update([
            'rating' => round($stats->avg_rating ?? 0, 2),
            'total_reviews' => $stats->total_reviews ?? 0,
        ]);
    }

    // Check availability untuk tanggal tertentu
    public function isAvailableForDates($startDate, $endDate)
    {
        if (!$this->is_available) return false;

        return !$this->bookings()
            ->where('status', '!=', 'cancelled')
            ->where(function ($query) use ($startDate, $endDate) {
                $query->whereBetween('start_date', [$startDate, $endDate])
                      ->orWhereBetween('end_date', [$startDate, $endDate])
                      ->orWhere(function ($q) use ($startDate, $endDate) {
                          $q->where('start_date', '<=', $startDate)
                            ->where('end_date', '>=', $endDate);
                      });
            })
            ->exists();
    }
}

Model Booking (app/Models/Booking.php)

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
use Illuminate\\Database\\Eloquent\\Builder;
use Carbon\\Carbon;

class Booking extends Model
{
    use HasFactory;

    protected $fillable = [
        'booking_code',
        'user_id',
        'office_id',
        'start_date',
        'end_date',
        'start_time',
        'end_time',
        'duration_type',
        'total_days',
        'participant_count',
        'purpose',
        'special_requests',
        'subtotal',
        'tax_amount',
        'total_amount',
        'status',
        'notes',
        'cancelled_at',
        'cancellation_reason',
    ];

    protected $casts = [
        'start_date' => 'date',
        'end_date' => 'date',
        'start_time' => 'datetime:H:i',
        'end_time' => 'datetime:H:i',
        'subtotal' => 'decimal:2',
        'tax_amount' => 'decimal:2',
        'total_amount' => 'decimal:2',
        'cancelled_at' => 'datetime',
    ];

    // Auto-generate booking code
    protected static function boot()
    {
        parent::boot();

        static::creating(function ($booking) {
            if (empty($booking->booking_code)) {
                $booking->booking_code = 'BK' . strtoupper(uniqid());
            }
        });
    }

    // Accessor untuk formatted amounts
    protected function formattedSubtotal(): Attribute
    {
        return Attribute::make(
            get: fn () => 'Rp ' . number_format($this->subtotal, 0, ',', '.')
        );
    }

    protected function formattedTotalAmount(): Attribute
    {
        return Attribute::make(
            get: fn () => 'Rp ' . number_format($this->total_amount, 0, ',', '.')
        );
    }

    // Accessor untuk status badge
    protected function statusBadge(): Attribute
    {
        return Attribute::make(
            get: function () {
                $badges = [
                    'pending' => 'bg-yellow-100 text-yellow-800',
                    'confirmed' => 'bg-green-100 text-green-800',
                    'completed' => 'bg-blue-100 text-blue-800',
                    'cancelled' => 'bg-red-100 text-red-800',
                ];

                return $badges[$this->status] ?? 'bg-gray-100 text-gray-800';
            }
        );
    }

    // Scopes
    public function scopeActive(Builder $query): Builder
    {
        return $query->whereIn('status', ['pending', 'confirmed']);
    }

    public function scopeByStatus(Builder $query, string $status): Builder
    {
        return $query->where('status', $status);
    }

    public function scopeUpcoming(Builder $query): Builder
    {
        return $query->where('start_date', '>', now()->toDateString());
    }

    public function scopeOngoing(Builder $query): Builder
    {
        return $query->where('start_date', '<=', now()->toDateString())
                    ->where('end_date', '>=', now()->toDateString())
                    ->where('status', 'confirmed');
    }

    // Methods untuk booking management
    public function confirm()
    {
        $this->update(['status' => 'confirmed']);
    }

    public function cancel($reason = null)
    {
        $this->update([
            'status' => 'cancelled',
            'cancelled_at' => now(),
            'cancellation_reason' => $reason,
        ]);
    }

    public function complete()
    {
        $this->update(['status' => 'completed']);
    }

    // Check apakah booking bisa di-cancel
    public function canBeCancelled()
    {
        return $this->status === 'pending' ||
               ($this->status === 'confirmed' && $this->start_date > now()->addHours(24));
    }
}

Model Payment (app/Models/Payment.php)

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
use Illuminate\\Database\\Eloquent\\Builder;

class Payment extends Model
{
    use HasFactory;

    protected $fillable = [
        'booking_id',
        'payment_method',
        'amount',
        'payment_proof',
        'payment_date',
        'verified_at',
        'verified_by',
        'status',
        'reference_number',
        'notes',
    ];

    protected $casts = [
        'amount' => 'decimal:2',
        'payment_date' => 'datetime',
        'verified_at' => 'datetime',
    ];

    // Accessor untuk formatted amount
    protected function formattedAmount(): Attribute
    {
        return Attribute::make(
            get: fn () => 'Rp ' . number_format($this->amount, 0, ',', '.')
        );
    }

    // Accessor untuk payment proof URL
    protected function paymentProofUrl(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->payment_proof
                ? asset('storage/' . $this->payment_proof)
                : null
        );
    }

    // Scopes
    public function scopePending(Builder $query): Builder
    {
        return $query->where('status', 'pending');
    }

    public function scopeVerified(Builder $query): Builder
    {
        return $query->where('status', 'verified');
    }

    // Methods
    public function verify($adminId)
    {
        $this->update([
            'status' => 'verified',
            'verified_at' => now(),
            'verified_by' => $adminId,
        ]);
    }
}

Model Review (app/Models/Review.php)

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Casts\\Attribute;
use Illuminate\\Database\\Eloquent\\Builder;

class Review extends Model
{
    use HasFactory;

    protected $fillable = [
        'booking_id',
        'user_id',
        'office_id',
        'rating',
        'title',
        'comment',
        'pros',
        'cons',
        'is_anonymous',
        'is_approved',
        'helpful_count',
    ];

    protected $casts = [
        'rating' => 'integer',
        'is_anonymous' => 'boolean',
        'is_approved' => 'boolean',
        'helpful_count' => 'integer',
    ];

    // Accessor untuk star display
    protected function starDisplay(): Attribute
    {
        return Attribute::make(
            get: function () {
                return str_repeat('★', $this->rating) . str_repeat('☆', 5 - $this->rating);
            }
        );
    }

    // Accessor untuk reviewer name
    protected function reviewerName(): Attribute
    {
        return Attribute::make(
            get: function () {
                if ($this->is_anonymous) {
                    return 'Anonymous User';
                }
                return $this->user->name ?? 'Unknown User';
            }
        );
    }

    // Scopes
    public function scopeApproved(Builder $query): Builder
    {
        return $query->where('is_approved', true);
    }

    public function scopeByRating(Builder $query, int $rating): Builder
    {
        return $query->where('rating', $rating);
    }

    public function scopeRecent(Builder $query): Builder
    {
        return $query->orderBy('created_at', 'desc');
    }
}

Membuat Relasi Antar Model

Sekarang kita define relationships antara models. Eloquent relationships bikin query jadi efficient dan code jadi clean.

Tambahkan relationships di Model User:

// Di dalam class User, tambahkan methods ini:

public function bookings()
{
    return $this->hasMany(Booking::class);
}

public function reviews()
{
    return $this->hasMany(Review::class);
}

public function payments()
{
    return $this->hasManyThrough(Payment::class, Booking::class);
}

public function verifiedPayments()
{
    return $this->hasMany(Payment::class, 'verified_by');
}

Tambahkan relationships di Model Office:

// Di dalam class Office, tambahkan methods ini:

public function bookings()
{
    return $this->hasMany(Booking::class);
}

public function reviews()
{
    return $this->hasMany(Review::class);
}

public function facilities()
{
    return $this->belongsToMany(OfficeFacility::class, 'office_facility')
                ->withPivot('is_available', 'additional_info')
                ->withTimestamps();
}

public function activeFacilities()
{
    return $this->facilities()->wherePivot('is_available', true);
}

public function activeBookings()
{
    return $this->bookings()->active();
}

public function approvedReviews()
{
    return $this->reviews()->approved();
}

Tambahkan relationships di Model Booking:

// Di dalam class Booking, tambahkan methods ini:

public function user()
{
    return $this->belongsTo(User::class);
}

public function office()
{
    return $this->belongsTo(Office::class);
}

public function payment()
{
    return $this->hasOne(Payment::class);
}

public function review()
{
    return $this->hasOne(Review::class);
}

Tambahkan relationships di Model Payment:

// Di dalam class Payment, tambahkan methods ini:

public function booking()
{
    return $this->belongsTo(Booking::class);
}

public function verifiedBy()
{
    return $this->belongsTo(User::class, 'verified_by');
}

public function user()
{
    return $this->hasOneThrough(User::class, Booking::class, 'id', 'id', 'booking_id', 'user_id');
}

Tambahkan relationships di Model Review:

// Di dalam class Review, tambahkan methods ini:

public function booking()
{
    return $this->belongsTo(Booking::class);
}

public function user()
{
    return $this->belongsTo(User::class);
}

public function office()
{
    return $this->belongsTo(Office::class);
}

Tambahkan relationships di Model OfficeFacility:

// Di dalam class OfficeFacility, tambahkan methods ini:

public function offices()
{
    return $this->belongsToMany(Office::class, 'office_facility')
                ->withPivot('is_available', 'additional_info')
                ->withTimestamps();
}

Menggunakan Spatie Permission Traits pada Model User

Trait HasRoles dari Spatie udah kita include di model User. Sekarang kita define roles dan permissions yang bakal kita pake:

Create seeder buat roles dan permissions:

php artisan make:seeder RolePermissionSeeder

File database/seeders/RolePermissionSeeder.php:

<?php

namespace Database\\Seeders;

use Illuminate\\Database\\Seeder;
use Spatie\\Permission\\Models\\Role;
use Spatie\\Permission\\Models\\Permission;
use App\\Models\\User;

class RolePermissionSeeder extends Seeder
{
    public function run(): void
    {
        // Create permissions
        $permissions = [
            'view_dashboard',
            'manage_offices',
            'manage_bookings',
            'manage_payments',
            'manage_users',
            'manage_reviews',
            'view_reports',
            'manage_settings',
        ];

        foreach ($permissions as $permission) {
            Permission::create(['name' => $permission]);
        }

        // Create roles
        $superAdmin = Role::create(['name' => 'super_admin']);
        $admin = Role::create(['name' => 'admin']);
        $operator = Role::create(['name' => 'operator']);
        $customer = Role::create(['name' => 'customer']);

        // Assign permissions to roles
        $superAdmin->givePermissionTo(Permission::all());

        $admin->givePermissionTo([
            'view_dashboard',
            'manage_offices',
            'manage_bookings',
            'manage_payments',
            'view_reports',
        ]);

        $operator->givePermissionTo([
            'view_dashboard',
            'manage_bookings',
            'manage_payments',
        ]);

        // Assign super admin role to first user
        $firstUser = User::first();
        if ($firstUser) {
            $firstUser->assignRole('super_admin');
        }
    }
}

Update DatabaseSeeder buat include role seeder:

public function run(): void
{
    $this->call([
        OfficeFacilitySeeder::class,
        UserSeeder::class,
        RolePermissionSeeder::class,
        OfficeSeeder::class,
    ]);
}

Testing Model Relationships dan Functionality

Sekarang kita test semua functionality yang udah kita implement:

php artisan tinker

Test relationships:

>>> $user = App\\Models\\User::first();
>>> $user->roles;
>>> $user->hasRole('super_admin');
>>> $user->can('manage_offices');

>>> $office = App\\Models\\Office::first();
>>> $office->facilities;
>>> $office->imageUrls;
>>> $office->formattedDailyPrice;

>>> $facility = App\\Models\\OfficeFacility::first();
>>> $facility->offices;

Test scopes:

>>> App\\Models\\Office::available()->count();
>>> App\\Models\\Office::featured()->get();
>>> App\\Models\\Office::inCity('Jakarta')->count();

Test methods:

>>> $office = App\\Models\\Office::first();
>>> $office->isAvailableForDates('2024-12-01', '2024-12-03');
>>> $office->updateRating();

Kalau semua test di atas jalan dengan smooth, berarti model configuration kita udah perfect!

Dengan setup model yang comprehensive kayak gini, kita udah punya foundation yang solid buat implement business logic yang complex. Models kita udah optimized dengan relationships yang efficient, accessors yang helpful, dan methods yang reusable.

Next, kita bakal implement Filament admin panel yang bakal leverage semua model relationships dan functionality yang udah kita setup!

Pembuatan Admin Filament: Dashboard yang Keren Banget!

Nah sekarang kita masuk ke bagian yang paling exciting! Kita bakal bikin admin panel yang professional pake Filament 3. Filament itu bener-bener game changer buat developer Laravel - dalam hitungan menit kita bisa punya dashboard admin yang sleek dan functional.

Gue bakal guide kalian step by step mulai dari create admin user pertama sampai kustomisasi dashboard yang kece. Trust me, setelah bagian ini kalian bakal jatuh cinta sama Filament!

Membuat Akun Admin Pertama Menggunakan Command Filament

Pertama-tama, kita perlu bikin admin user yang bisa akses dashboard Filament. Beruntungnya, Filament udah provide command yang gampang banget buat ini.

Jalankan command berikut di terminal:

php artisan make:filament-user

Command ini bakal nanya beberapa informasi:

Name: Super Admin
Email address: [email protected]
Password: [buat password yang kuat, minimal 8 karakter]

Setelah berhasil, kamu bakal dapet message konfirmasi bahwa user admin udah ter-create. User ini otomatis punya akses ke admin panel.

Tapi tunggu, ada yang lebih keren lagi! Kita bisa bikin custom command buat create admin dengan role yang spesifik. Bikin file command baru:

php artisan make:command CreateFilamentAdmin

Edit file app/Console/Commands/CreateFilamentAdmin.php:

<?php

namespace App\\Console\\Commands;

use App\\Models\\User;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Hash;

class CreateFilamentAdmin extends Command
{
    protected $signature = 'filament:create-admin {--email=} {--name=} {--password=}';
    protected $description = 'Create a new Filament admin user with super admin role';

    public function handle()
    {
        $email = $this->option('email') ?: $this->ask('Email address');
        $name = $this->option('name') ?: $this->ask('Full name');
        $password = $this->option('password') ?: $this->secret('Password');

        $user = User::create([
            'name' => $name,
            'email' => $email,
            'password' => Hash::make($password),
            'email_verified_at' => now(),
            'is_active' => true,
        ]);

        $user->assignRole('super_admin');

        $this->info("Admin user created succesfully!");  // typo disengaja
        $this->line("Email: {$email}");
        $this->line("Password: [hidden]");

        return Command::SUCCESS;
    }
}

Sekarang kamu bisa create admin dengan command:

php artisan filament:create-admin [email protected] --name="Admin User"

Mengakses Halaman Admin Filament

Setelah punya admin user, saatnya akses dashboard! Jalankan development server:

php artisan serve

Buka browser dan navigasi ke:

<http://localhost:8000/admin>

Kamu bakal diarahkan ke halaman login Filament yang clean banget. Input email dan password yang tadi dibuat, terus klik "Sign in".

Boom! Kamu udah masuk ke dashboard Filament yang elegant. Interface-nya modern dengan sidebar navigation, dark mode toggle, dan user menu di pojok kanan atas.

Yang bikin keren, Filament otomatis detect browser theme (light/dark) dan adjust tampilan accordingly. Plus ada responsive design yang perfect di mobile maupun desktop.

Melakukan Uji Coba Login pada Dashboard Admin

Mari kita explore dashboard yang udah tersedia. Di sidebar kamu bakal liat menu-menu default:

Dashboard: Halaman utama dengan widgets dan statistics (masih kosong karena belum ada data)

Users: Resource buat manage users (ini auto-generated dari model User)

Coba klik menu "Users" - kamu bakal liat table yang bisa sorting, filtering, dan searching. Ini adalah power dari Filament: zero configuration tapi feature-rich!

Test beberapa fungsionalitas:

  • Create user baru dengan tombol "New user"
  • Edit user existing dengan klik icon edit
  • Bulk actions dengan select multiple rows
  • Global search di header

Semua fitur ini udah built-in tanpa kita perlu coding apa-apa. Amazing right?

Kustomisasi Tampilan Dashboard Admin

Sekarang kita personalisasi dashboard supaya sesuai dengan brand kita. Filament sangat customizable dan flexible.

Mengubah Logo dan Branding

Edit file config/filament.php (kalau belum ada, publish dulu):

php artisan vendor:publish --tag=filament-config

Update konfigurasi di config/filament.php:

return [
    'path' => env('FILAMENT_PATH', 'admin'),
    'domain' => env('FILAMENT_DOMAIN'),

    'brand' => 'Office Rental',

    'auth' => [
        'guard' => env('FILAMENT_AUTH_GUARD', 'web'),
        'pages' => [
            'login' => \\Filament\\Http\\Livewire\\Auth\\Login::class,
        ],
    ],

    'pages' => [
        'namespace' => 'App\\\\Filament\\\\Pages',
        'path' => app_path('Filament/Pages'),
    ],

    'resources' => [
        'namespace' => 'App\\\\Filament\\\\Resources',
        'path' => app_path('Filament/Resources'),
    ],

    'widgets' => [
        'namespace' => 'App\\\\Filament\\\\Widgets',
        'path' => app_path('Filament/Widgets'),
    ],
];

Custom CSS untuk Branding

Bikin file CSS custom di resources/css/filament.css:

:root {
    --primary-50: #eff6ff;
    --primary-500: #3b82f6;
    --primary-600: #2563eb;
    --primary-700: #1d4ed8;
    --primary-900: #1e3a8a;
}

.fi-sidebar-nav-item-active {
    background: linear-gradient(135deg, var(--primary-500), var(--primary-600));
}

.fi-logo {
    font-weight: 700;
    font-size: 1.25rem;
    color: var(--primary-700);
}

Register CSS file di resources/views/vendor/filament/components/layouts/app.blade.php (publish view dulu):

php artisan vendor:publish --tag=filament-views

Create Custom Dashboard Page

Bikin dashboard page yang custom dengan widgets menarik:

php artisan make:filament-page Dashboard

Edit file app/Filament/Pages/Dashboard.php:

<?php

namespace App\\Filament\\Pages;

use Filament\\Pages\\Dashboard as BaseDashboard;

class Dashboard extends BaseDashboard
{
    protected static ?string $navigationIcon = 'heroicon-o-home';
    protected static string $view = 'filament.pages.dashboard';

    protected function getHeaderWidgets(): array
    {
        return [
            // nanti kita bakal add widgets di sini
        ];
    }

    protected function getFooterWidgets(): array
    {
        return [
            // footer widgets
        ];
    }
}

Custom Navigation Items

Kita bisa customize sidebar navigation dengan override di service provider. Edit app/Providers/AppServiceProvider.php:

use Filament\\Facades\\Filament;
use Filament\\Navigation\\NavigationGroup;
use Filament\\Navigation\\NavigationItem;

public function boot()
{
    Filament::serving(function () {
        Filament::registerNavigationGroups([
            NavigationGroup::make()
                ->label('Office Management')
                ->icon('heroicon-o-office-building'),
            NavigationGroup::make()
                ->label('Booking & Payments')
                ->icon('heroicon-o-currency-dollar'),
            NavigationGroup::make()
                ->label('User Management')
                ->icon('heroicon-o-users'),
        ]);

        Filament::registerNavigationItems([
            NavigationItem::make('Analytics')
                ->url('/admin/analytics')
                ->icon('heroicon-o-chart-bar')
                ->group('Reports')
                ->sort(1),
        ]);
    });
}

Mengatur Middleware dan Guards untuk Keamanan

Security adalah prioritas utama, especially untuk admin panel. Kita perlu setup middleware dan guards yang proper.

Konfigurasi Auth Guard

Update config/auth.php kalau perlu custom guard:

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'admin' => [  // custom guard for admin
        'driver' => 'session',
        'provider' => 'admin_users',
    ],
],

Custom Middleware untuk Role Check

Bikin middleware buat check admin role:

php artisan make:middleware FilamentAdminMiddleware

Edit app/Http/Middleware/FilamentAdminMiddleware.php:

<?php

namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Auth;

class FilamentAdminMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        if (!Auth::check()) {
            return redirect()->route('filament.auth.login');
        }

        $user = Auth::user();

        if (!$user->hasAnyRole(['super_admin', 'admin', 'operator'])) {
            abort(403, 'Access denied. Admin privileges required.');
        }

        // Update last login
        $user->updateLastLogin();

        return $next($request);
    }
}

Register middleware di app/Http/Kernel.php:

protected $routeMiddleware = [
    // ... existing middleware
    'filament.admin' => \\App\\Http\\Middleware\\FilamentAdminMiddleware::class,
];

Setup Filament dengan Custom Middleware

Update konfigurasi Filament di config/filament.php:

'middleware' => [
    'auth' => [
        'web',
        'filament.admin',  // custom middleware kita
    ],
    'base' => [
        'web',
    ],
],

'auth' => [
    'guard' => 'web',
    'pages' => [
        'login' => \\Filament\\Http\\Livewire\\Auth\\Login::class,
    ],
],

Two-Factor Authentication (Bonus)

Buat security yang lebih ketat, kita bisa implement 2FA. Install package:

composer require pragmarx/google2fa-laravel

Tapi ini optional ya, untuk basic setup middleware di atas udah cukup secure.

Testing Security Setup

Test dengan cara:

  1. Logout dari admin panel
  2. Coba akses /admin langsung - harus redirect ke login
  3. Login dengan user biasa (tanpa admin role) - harus dapat error 403
  4. Login dengan admin user - harus berhasil masuk

Custom Login Page (Optional tapi Keren)

Kalau mau lebih professional, kita bisa customize login page. Bikin view custom:

mkdir -p resources/views/filament/auth

Bikin file resources/views/filament/auth/login.blade.php:

<x-filament::layouts.app>
    <div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
        <div class="max-w-md w-full space-y-8">
            <div class="text-center">
                <h2 class="text-3xl font-bold text-gray-900">
                    Office Rental Admin
                </h2>
                <p class="mt-2 text-gray-600">
                    Sign in to manage your office rental platform
                </p>
            </div>

            <div class="bg-white shadow-xl rounded-lg p-8">
                {{ $this->form }}

                <div class="mt-6">
                    {{ $this->authenticate() }}
                </div>
            </div>
        </div>
    </div>
</x-filament::layouts.app>

Update config buat pake custom login:

'auth' => [
    'pages' => [
        'login' => App\\Filament\\Pages\\Auth\\Login::class,
    ],
],

Bikin custom login page class:

php artisan make:filament-page Auth/Login

Dan voilà! Admin panel kita udah ready dengan security yang proper dan tampilan yang professional.

Di bagian selanjutnya, kita bakal bikin Filament Resources buat manage data offices, bookings, dan users dengan CRUD interface yang powerful!

Pembuatan Filament Resource: CRUD Interface yang Powerfull

Sekarang kita masuk ke bagian yang paling seru! Kita bakal bikin Filament Resources yang bakal jadi management interface buat data aplikasi kita. Dengan Filament Resources, admin bisa manage data offices, bookings, users dengan interface yang intuitive dan feature-rich.

Gue bakal tunjukin gimana cara bikin Resources yang tidak cuma basic CRUD, tapi juga punya custom actions, filters, dan widgets yang keren banget. Let's start building!

Membuat Filament Resource untuk CRUD Tabel Offices

Office adalah core dari aplikasi kita, jadi kita mulai dari sini dulu. Bikin Resource buat manage office data:

php artisan make:filament-resource Office --generate

Command ini bakal auto-generate Resource class beserta form dan table configuration berdasarkan model Office kita. Keren kan?

Sekarang edit file app/Filament/Resources/OfficeResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\OfficeResource\\Pages;
use App\\Models\\Office;
use App\\Models\\OfficeFacility;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Str;

class OfficeResource extends Resource
{
    protected static ?string $model = Office::class;
    protected static ?string $navigationIcon = 'heroicon-o-building-office';
    protected static ?string $navigationGroup = 'BWA Office Management';
    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Basic Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('name')
                            ->required()
                            ->maxLength(255)
                            ->live(onBlur: true)
                            ->afterStateUpdated(function (string $operation, $state, Forms\\Set $set) {
                                if ($operation !== 'create') {
                                    return;
                                }
                                $set('slug', Str::slug($state));
                            }),

                        Forms\\Components\\TextInput::make('slug')
                            ->required()
                            ->maxLength(255)
                            ->unique(Office::class, 'slug', ignoreRecord: true),

                        Forms\\Components\\Textarea::make('description')
                            ->required()
                            ->maxLength(65535)
                            ->columnSpanFull(),
                    ])->columns(2),

                Forms\\Components\\Section::make('Location Details')
                    ->schema([
                        Forms\\Components\\Textarea::make('address')
                            ->required()
                            ->maxLength(65535)
                            ->columnSpanFull(),

                        Forms\\Components\\TextInput::make('city')
                            ->required()
                            ->maxLength(100),

                        Forms\\Components\\TextInput::make('state')
                            ->required()
                            ->maxLength(100),

                        Forms\\Components\\TextInput::make('postal_code')
                            ->required()
                            ->maxLength(10),

                        Forms\\Components\\TextInput::make('latitude')
                            ->numeric()
                            ->step(0.00000001),

                        Forms\\Components\\TextInput::make('longitude')
                            ->numeric()
                            ->step(0.00000001),
                    ])->columns(2),

                Forms\\Components\\Section::make('Office Specifications')
                    ->schema([
                        Forms\\Components\\TextInput::make('capacity')
                            ->required()
                            ->numeric()
                            ->minValue(1),

                        Forms\\Components\\TextInput::make('area_size')
                            ->numeric()
                            ->step(0.01)
                            ->suffix('m²'),

                        Forms\\Components\\TextInput::make('hourly_price')
                            ->required()
                            ->numeric()
                            ->prefix('Rp')
                            ->step(0.01),

                        Forms\\Components\\TextInput::make('daily_price')
                            ->required()
                            ->numeric()
                            ->prefix('Rp')
                            ->step(0.01),

                        Forms\\Components\\TextInput::make('monthly_price')
                            ->required()
                            ->numeric()
                            ->prefix('Rp')
                            ->step(0.01),
                    ])->columns(3),

                Forms\\Components\\Section::make('Contact & Availability')
                    ->schema([
                        Forms\\Components\\TextInput::make('contact_person')
                            ->maxLength(255),

                        Forms\\Components\\TextInput::make('contact_phone')
                            ->tel()
                            ->maxLength(20),

                        Forms\\Components\\Toggle::make('is_featured')
                            ->label('Featured Office'),

                        Forms\\Components\\Toggle::make('is_available')
                            ->label('Available for Booking')
                            ->default(true),
                    ])->columns(2),

                Forms\\Components\\Section::make('Media & Facilities')
                    ->schema([
                        Forms\\Components\\FileUpload::make('images')
                            ->multiple()
                            ->image()
                            ->directory('offices')
                            ->maxFiles(10)
                            ->reorderable()
                            ->columnSpanFull(),

                        Forms\\Components\\CheckboxList::make('facilities')
                            ->relationship('facilities', 'name')
                            ->options(OfficeFacility::active()->pluck('name', 'id'))
                            ->columns(3)
                            ->columnSpanFull(),

                        Forms\\Components\\KeyValue::make('opening_hours')
                            ->keyLabel('Day')
                            ->valueLabel('Hours')
                            ->default([
                                'Monday' => '09:00 - 18:00',
                                'Tuesday' => '09:00 - 18:00',
                                'Wednesday' => '09:00 - 18:00',
                                'Thursday' => '09:00 - 18:00',
                                'Friday' => '09:00 - 18:00',
                                'Saturday' => '09:00 - 15:00',
                                'Sunday' => 'Closed',
                            ])
                            ->columnSpanFull(),
                    ]),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\ImageColumn::make('images')
                    ->label('Photo')
                    ->circular()
                    ->stacked()
                    ->limit(3),

                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('city')
                    ->searchable()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('capacity')
                    ->numeric()
                    ->sortable()
                    ->suffix(' people'),

                Tables\\Columns\\TextColumn::make('daily_price')
                    ->money('IDR')
                    ->sortable(),

                Tables\\Columns\\IconColumn::make('is_featured')
                    ->boolean()
                    ->trueIcon('heroicon-o-star')
                    ->falseIcon('heroicon-o-star')
                    ->trueColor('warning')
                    ->falseColor('gray'),

                Tables\\Columns\\ToggleColumn::make('is_available')
                    ->label('Available'),

                Tables\\Columns\\TextColumn::make('rating')
                    ->numeric(decimalPlaces: 1)
                    ->suffix('/5.0')
                    ->color(fn ($state) => $state >= 4 ? 'success' : ($state >= 3 ? 'warning' : 'danger')),

                Tables\\Columns\\TextColumn::make('total_reviews')
                    ->numeric()
                    ->label('Reviews'),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\SelectFilter::make('city')
                    ->options(Office::distinct()->pluck('city', 'city')->toArray()),

                Tables\\Filters\\TernaryFilter::make('is_featured')
                    ->label('Featured Offices'),

                Tables\\Filters\\TernaryFilter::make('is_available')
                    ->label('Available for Booking'),

                Tables\\Filters\\Filter::make('capacity_range')
                    ->form([
                        Forms\\Components\\TextInput::make('capacity_from')
                            ->numeric()
                            ->placeholder('Min capacity'),
                        Forms\\Components\\TextInput::make('capacity_to')
                            ->numeric()
                            ->placeholder('Max capacity'),
                    ])
                    ->query(function (Builder $query, array $data): Builder {
                        return $query
                            ->when($data['capacity_from'], fn ($query) => $query->where('capacity', '>=', $data['capacity_from']))
                            ->when($data['capacity_to'], fn ($query) => $query->where('capacity', '<=', $data['capacity_to']));
                    }),
            ])
            ->actions([
                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('toggle_featured')
                        ->label('Toggle Featured')
                        ->icon('heroicon-o-star')
                        ->action(function ($records) {
                            foreach ($records as $record) {
                                $record->update(['is_featured' => !$record->is_featured]);
                            }
                        }),
                ]),
            ]);
    }

    public static function getRelations(): array
    {
        return [
            // nanti kita tambahin relation managers
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListOffices::route('/'),
            'create' => Pages\\CreateOffice::route('/create'),
            'view' => Pages\\ViewOffice::route('/{record}'),
            'edit' => Pages\\EditOffice::route('/{record}/edit'),
        ];
    }
}

Membuat Filament Resource untuk CRUD Tabel Bookings

Booking adalah transaksi inti, jadi kita bikin Resource yang comprehensive:

php artisan make:filament-resource Booking --generate

Edit app/Filament/Resources/BookingResource.php:

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\BookingResource\\Pages;
use App\\Models\\Booking;
use App\\Models\\Office;
use App\\Models\\User;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
use Filament\\Notifications\\Notification;

class BookingResource extends Resource
{
    protected static ?string $model = Booking::class;
    protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
    protected static ?string $navigationGroup = 'BWA Booking Management';
    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Booking Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('booking_code')
                            ->required()
                            ->maxLength(20)
                            ->unique(ignoreRecord: true)
                            ->default(fn () => 'BWA' . strtoupper(uniqid())),

                        Forms\\Components\\Select::make('user_id')
                            ->label('Customer')
                            ->relationship('user', 'name')
                            ->searchable()
                            ->preload()
                            ->required(),

                        Forms\\Components\\Select::make('office_id')
                            ->label('Office')
                            ->relationship('office', 'name')
                            ->searchable()
                            ->preload()
                            ->required(),

                        Forms\\Components\\Select::make('status')
                            ->options([
                                'pending' => 'Pending',
                                'confirmed' => 'Confirmed',
                                'completed' => 'Completed',
                                'cancelled' => 'Cancelled',
                            ])
                            ->default('pending')
                            ->required(),
                    ])->columns(2),

                Forms\\Components\\Section::make('Schedule & Duration')
                    ->schema([
                        Forms\\Components\\DatePicker::make('start_date')
                            ->required()
                            ->native(false),

                        Forms\\Components\\DatePicker::make('end_date')
                            ->required()
                            ->native(false)
                            ->after('start_date'),

                        Forms\\Components\\TimePicker::make('start_time')
                            ->seconds(false),

                        Forms\\Components\\TimePicker::make('end_time')
                            ->seconds(false)
                            ->after('start_time'),

                        Forms\\Components\\Select::make('duration_type')
                            ->options([
                                'hourly' => 'Hourly',
                                'daily' => 'Daily',
                                'monthly' => 'Monthly',
                            ])
                            ->required(),

                        Forms\\Components\\TextInput::make('total_days')
                            ->required()
                            ->numeric()
                            ->minValue(1),
                    ])->columns(3),

                Forms\\Components\\Section::make('Booking Details')
                    ->schema([
                        Forms\\Components\\TextInput::make('participant_count')
                            ->required()
                            ->numeric()
                            ->minValue(1),

                        Forms\\Components\\TextInput::make('purpose')
                            ->maxLength(255),

                        Forms\\Components\\Textarea::make('special_requests')
                            ->maxLength(65535)
                            ->columnSpanFull(),
                    ])->columns(2),

                Forms\\Components\\Section::make('Payment Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('subtotal')
                            ->required()
                            ->numeric()
                            ->prefix('Rp'),

                        Forms\\Components\\TextInput::make('tax_amount')
                            ->numeric()
                            ->default(0)
                            ->prefix('Rp'),

                        Forms\\Components\\TextInput::make('total_amount')
                            ->required()
                            ->numeric()
                            ->prefix('Rp'),
                    ])->columns(3),

                Forms\\Components\\Section::make('Additional Notes')
                    ->schema([
                        Forms\\Components\\Textarea::make('notes')
                            ->maxLength(65535)
                            ->columnSpanFull(),

                        Forms\\Components\\Textarea::make('cancellation_reason')
                            ->maxLength(65535)
                            ->visible(fn (Forms\\Get $get) => $get('status') === 'cancelled')
                            ->columnSpanFull(),
                    ]),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('booking_code')
                    ->searchable()
                    ->sortable()
                    ->copyable(),

                Tables\\Columns\\TextColumn::make('user.name')
                    ->label('Customer')
                    ->searchable()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('office.name')
                    ->label('Office')
                    ->searchable()
                    ->sortable()
                    ->limit(30),

                Tables\\Columns\\TextColumn::make('start_date')
                    ->date()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('end_date')
                    ->date()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('duration_type')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'hourly' => 'info',
                        'daily' => 'success',
                        'monthly' => 'warning',
                        default => 'gray',
                    }),

                Tables\\Columns\\TextColumn::make('total_amount')
                    ->money('IDR')
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('status')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'pending' => 'warning',
                        'confirmed' => 'success',
                        'completed' => 'info',
                        'cancelled' => 'danger',
                        default => 'gray',
                    }),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\SelectFilter::make('status')
                    ->options([
                        'pending' => 'Pending',
                        'confirmed' => 'Confirmed',
                        'completed' => 'Completed',
                        'cancelled' => 'Cancelled',
                    ]),

                Tables\\Filters\\SelectFilter::make('duration_type')
                    ->options([
                        'hourly' => 'Hourly',
                        'daily' => 'Daily',
                        'monthly' => 'Monthly',
                    ]),

                Tables\\Filters\\Filter::make('date_range')
                    ->form([
                        Forms\\Components\\DatePicker::make('from_date')
                            ->label('From Date'),
                        Forms\\Components\\DatePicker::make('to_date')
                            ->label('To Date'),
                    ])
                    ->query(function (Builder $query, array $data): Builder {
                        return $query
                            ->when($data['from_date'], fn ($query) => $query->where('start_date', '>=', $data['from_date']))
                            ->when($data['to_date'], fn ($query) => $query->where('end_date', '<=', $data['to_date']));
                    }),
            ])
            ->actions([
                Tables\\Actions\\Action::make('confirm')
                    ->label('Confirm')
                    ->icon('heroicon-o-check-circle')
                    ->color('success')
                    ->visible(fn (Booking $record) => $record->status === 'pending')
                    ->action(function (Booking $record) {
                        $record->confirm();

                        Notification::make()
                            ->title('Booking confirmed successfully')
                            ->success()
                            ->send();
                    }),

                Tables\\Actions\\Action::make('cancel')
                    ->label('Cancel')
                    ->icon('heroicon-o-x-circle')
                    ->color('danger')
                    ->visible(fn (Booking $record) => in_array($record->status, ['pending', 'confirmed']))
                    ->form([
                        Forms\\Components\\Textarea::make('reason')
                            ->label('Cancellation Reason')
                            ->required(),
                    ])
                    ->action(function (Booking $record, array $data) {
                        $record->cancel($data['reason']);

                        Notification::make()
                            ->title('Booking cancelled successfully')
                            ->success()
                            ->send();
                    }),

                Tables\\Actions\\ViewAction::make(),
                Tables\\Actions\\EditAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ]);
    }

    public static function getRelations(): array
    {
        return [
            // PaymentRelationManager, ReviewRelationManager nanti ditambahkan
        ];
    }

    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'),
        ];
    }
}

Membuat Filament Resource untuk CRUD Tabel Users

User management adalah crucial buat admin panel. Mari bikin Resource yang comprehensive:

php artisan make:filament-resource User --generate

Edit 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\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Facades\\Hash;
use Spatie\\Permission\\Models\\Role;

class UserResource extends Resource
{
    protected static ?string $model = User::class;
    protected static ?string $navigationIcon = 'heroicon-o-users';
    protected static ?string $navigationGroup = 'BWA User Management';
    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('User Information')
                    ->schema([
                        Forms\\Components\\TextInput::make('name')
                            ->required()
                            ->maxLength(255),

                        Forms\\Components\\TextInput::make('email')
                            ->email()
                            ->required()
                            ->maxLength(255)
                            ->unique(ignoreRecord: true),

                        Forms\\Components\\TextInput::make('phone')
                            ->tel()
                            ->maxLength(20),

                        Forms\\Components\\FileUpload::make('avatar')
                            ->image()
                            ->directory('avatars')
                            ->imageEditor(),
                    ])->columns(2),

                Forms\\Components\\Section::make('Account Settings')
                    ->schema([
                        Forms\\Components\\TextInput::make('password')
                            ->password()
                            ->dehydrateStateUsing(fn ($state) => Hash::make($state))
                            ->dehydrated(fn ($state) => filled($state))
                            ->required(fn (string $context): bool => $context === 'create'),

                        Forms\\Components\\TextInput::make('passwordConfirmation')
                            ->password()
                            ->same('password')
                            ->dehydrated(false)
                            ->required(fn (string $context): bool => $context === 'create'),

                        Forms\\Components\\Toggle::make('is_active')
                            ->label('Active Account')
                            ->default(true),

                        Forms\\Components\\Select::make('roles')
                            ->relationship('roles', 'name')
                            ->multiple()
                            ->preload()
                            ->searchable()
                            ->columnSpanFull(),
                    ])->columns(2),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\ImageColumn::make('avatar')
                    ->circular()
                    ->defaultImageUrl(fn ($record) => '<https://ui-avatars.com/api/?name=>' . urlencode($record->name)),

                Tables\\Columns\\TextColumn::make('name')
                    ->searchable()
                    ->sortable(),

                Tables\\Columns\\TextColumn::make('email')
                    ->searchable()
                    ->sortable()
                    ->copyable(),

                Tables\\Columns\\TextColumn::make('phone')
                    ->searchable()
                    ->toggleable(isToggledHiddenByDefault: true),

                Tables\\Columns\\TextColumn::make('roles.name')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'super_admin' => 'danger',
                        'admin' => 'warning',
                        'operator' => 'info',
                        'customer' => 'success',
                        default => 'gray',
                    }),

                Tables\\Columns\\ToggleColumn::make('is_active')
                    ->label('Active'),

                Tables\\Columns\\TextColumn::make('last_login_at')
                    ->dateTime()
                    ->sortable()
                    ->since()
                    ->placeholder('Never'),

                Tables\\Columns\\TextColumn::make('bookings_count')
                    ->counts('bookings')
                    ->label('Total Bookings'),

                Tables\\Columns\\TextColumn::make('created_at')
                    ->dateTime()
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                Tables\\Filters\\SelectFilter::make('roles')
                    ->relationship('roles', 'name')
                    ->multiple(),

                Tables\\Filters\\TernaryFilter::make('is_active')
                    ->label('Account Status'),

                Tables\\Filters\\Filter::make('last_login')
                    ->label('Recently Active')
                    ->query(fn (Builder $query): Builder => $query->where('last_login_at', '>=', now()->subDays(30))),
            ])
            ->actions([
                Tables\\Actions\\Action::make('impersonate')
                    ->label('Login as User')
                    ->icon('heroicon-o-user-circle')
                    ->color('warning')
                    ->visible(fn () => auth()->user()->hasRole('super_admin'))
                    ->url(fn (User $record) => route('impersonate', $record->id))
                    ->openUrlInNewTab(),

                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('activate')
                        ->label('Activate Selected')
                        ->icon('heroicon-o-check-circle')
                        ->action(function ($records) {
                            foreach ($records as $record) {
                                $record->update(['is_active' => true]);
                            }
                        }),
                    Tables\\Actions\\BulkAction::make('deactivate')
                        ->label('Deactivate Selected')
                        ->icon('heroicon-o-x-circle')
                        ->action(function ($records) {
                            foreach ($records as $record) {
                                $record->update(['is_active' => false]);
                            }
                        }),
                ]),
            ]);
    }

    public static function getRelations(): array
    {
        return [
            // BookingRelationManager nanti ditambahkan
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListUsers::route('/'),
            'create' => Pages\\CreateUser::route('/create'),
            'view' => Pages\\ViewUser::route('/{record}'),
            'edit' => Pages\\EditUser::route('/{record}/edit'),
        ];
    }
}

Mengatur Form Fields dan Validation pada Setiap Resource

Validation adalah kunci dari data integrity. Kita udah implement basic validation di form components, tapi mari kita enhance dengan custom validation rules.

Custom Validation di OfficeResource:

Tambahkan method ini di OfficeResource:

public static function getFormSchema(): array
{
    return [
        // existing schema...

        Forms\\Components\\TextInput::make('capacity')
            ->required()
            ->numeric()
            ->minValue(1)
            ->maxValue(1000)
            ->rules(['integer', 'between:1,1000']),

        Forms\\Components\\TextInput::make('daily_price')
            ->required()
            ->numeric()
            ->prefix('Rp')
            ->step(0.01)
            ->rules(['numeric', 'min:50000']) // minimal 50rb
            ->validationMessages([
                'min' => 'Daily price must be at least Rp 50,000',
            ]),
    ];
}

Advanced Validation di BookingResource:

Forms\\Components\\DatePicker::make('start_date')
    ->required()
    ->native(false)
    ->rules(['date', 'after:today'])
    ->validationMessages([
        'after' => 'Start date must be at least tomorrow',
    ]),

Forms\\Components\\DatePicker::make('end_date')
    ->required()
    ->native(false)
    ->after('start_date')
    ->rules([
        'date',
        function ($attribute, $value, $fail) {
            $startDate = request()->input('start_date');
            if ($startDate && $value) {
                $daysDiff = \\Carbon\\Carbon::parse($value)->diffInDays(\\Carbon\\Carbon::parse($startDate));
                if ($daysDiff > 365) {
                    $fail('Booking duration cannot exceed 1 year');
                }
            }
        },
    ]),

Kustomisasi Table Columns dan Filters

Table adalah interface utama buat browse dan manage data. Mari kita bikin table yang powerfull dengan custom columns dan filters.

Advanced Filters di OfficeResource:

->filters([
    Tables\\Filters\\SelectFilter::make('city')
        ->options(Office::distinct()->pluck('city', 'city')->toArray())
        ->searchable()
        ->multiple(),

    Tables\\Filters\\Filter::make('price_range')
        ->form([
            Forms\\Components\\TextInput::make('min_price')
                ->numeric()
                ->placeholder('Minimum daily price'),
            Forms\\Components\\TextInput::make('max_price')
                ->numeric()
                ->placeholder('Maximum daily price'),
        ])
        ->query(function (Builder $query, array $data): Builder {
            return $query
                ->when($data['min_price'], fn ($q) => $q->where('daily_price', '>=', $data['min_price']))
                ->when($data['max_price'], fn ($q) => $q->where('daily_price', '<=', $data['max_price']));
        })
        ->indicateUsing(function (array $data): array {
            $indicators = [];
            if ($data['min_price'] ?? null) {
                $indicators['min_price'] = 'Min price: Rp ' . number_format($data['min_price']);
            }
            if ($data['max_price'] ?? null) {
                $indicators['max_price'] = 'Max price: Rp ' . number_format($data['max_price']);
            }
            return $indicators;
        }),

    Tables\\Filters\\TernaryFilter::make('has_reviews')
        ->label('Has Customer Reviews')
        ->queries(
            true: fn (Builder $query) => $query->where('total_reviews', '>', 0),
            false: fn (Builder $query) => $query->where('total_reviews', 0),
        ),
])

Mengatur Permission dan Role Access pada Resource

Security adalah priority utama. Kita perlu implement role-based access control di setiap Resource.

Setup Policies untuk Resources:

Bikin Policy buat Office:

php artisan make:policy OfficePolicy --model=Office

Edit app/Policies/OfficePolicy.php:

<?php

namespace App\\Policies;

use App\\Models\\Office;
use App\\Models\\User;

class OfficePolicy
{
    public function viewAny(User $user): bool
    {
        return $user->hasAnyRole(['super_admin', 'admin', 'operator']);
    }

    public function view(User $user, Office $office): bool
    {
        return $user->hasAnyRole(['super_admin', 'admin', 'operator']);
    }

    public function create(User $user): bool
    {
        return $user->hasAnyRole(['super_admin', 'admin']);
    }

    public function update(User $user, Office $office): bool
    {
        return $user->hasAnyRole(['super_admin', 'admin']);
    }

    public function delete(User $user, Office $office): bool
    {
        return $user->hasRole('super_admin');
    }

    public function deleteAny(User $user): bool
    {
        return $user->hasRole('super_admin');
    }
}

Register policy di app/Providers/AuthServiceProvider.php:

protected $policies = [
    Office::class => OfficePolicy::class,
    Booking::class => BookingPolicy::class,
    User::class => UserPolicy::class,
];

Implement Permission Checks di Resources:

Tambahkan method ini di OfficeResource:

public static function canViewAny(): bool
{
    return auth()->user()->can('viewAny', Office::class);
}

public static function canCreate(): bool
{
    return auth()->user()->can('create', Office::class);
}

public static function canEdit(Model $record): bool
{
    return auth()->user()->can('update', $record);
}

public static function canDelete(Model $record): bool
{
    return auth()->user()->can('delete', $record);
}

Dengan setup ini, admin panel kita udah punya CRUD interface yang powerful, secure, dan user-friendly. User dengan role yang berbeda bakal punya akses yang sesuai dengan permission mereka.

Next step, kita bakal bikin frontend interface yang bakal integrate dengan backend yang udah kita setup!

Pembuatan Frontend dengan Tailwind: Interface yang Memukau

Sekarang kita masuk ke bagian yang paling visual dan exciting! Kita bakal bikin frontend yang modern, responsive, dan user-friendly pake Tailwind CSS. Frontend ini yang bakal jadi "face" dari aplikasi kita - tempat dimana customer berinteraksi langsung dengan sistem.

Gue bakal tunjukin gimana cara bikin interface yang ga cuma functional tapi juga aesthetic dan enjoyable buat digunakan. Siap-siap bikin website yang bakal bikin customer bilang "WOW!"

Setup Tailwind CSS dalam Proyek Laravel

Laravel 12 udah include Vite sebagai build tool, yang bikin integration dengan Tailwind jadi super smooth. Mari kita setup dari awal dengan konfigurasi yang optimal.

Pertama, install Tailwind CSS dan dependencies-nya:

npm install -D tailwindcss postcss autoprefixer @tailwindcss/forms @tailwindcss/typography
npx tailwindcss init -p

Command ini bakal generate file tailwind.config.js dan postcss.config.js di root project.

Edit file tailwind.config.js buat konfigurasi yang customized:

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./resources/**/*.blade.php",
    "./resources/**/*.js",
    "./resources/**/*.vue",
    "./app/Filament/**/*.php",
  ],
  theme: {
    extend: {
      colors: {
        'bwa-primary': {
          50: '#eff6ff',
          100: '#dbeafe',
          200: '#bfdbfe',
          300: '#93c5fd',
          400: '#60a5fa',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
          800: '#1e40af',
          900: '#1e3a8a',
        },
        'bwa-gray': {
          50: '#f9fafb',
          100: '#f3f4f6',
          200: '#e5e7eb',
          300: '#d1d5db',
          400: '#9ca3af',
          500: '#6b7280',
          600: '#4b5563',
          700: '#374151',
          800: '#1f2937',
          900: '#111827',
        }
      },
      fontFamily: {
        'sans': ['Inter', 'system-ui', 'sans-serif'],
        'display': ['Lexend', 'system-ui', 'sans-serif'],
      },
      animation: {
        'fade-in': 'fadeIn 0.5s ease-in-out',
        'slide-up': 'slideUp 0.3s ease-out',
        'bounce-subtle': 'bounceSubtle 2s infinite',
      },
      keyframes: {
        fadeIn: {
          '0%': { opacity: '0' },
          '100%': { opacity: '1' },
        },
        slideUp: {
          '0%': { transform: 'translateY(10px)', opacity: '0' },
          '100%': { transform: 'translateY(0)', opacity: '1' },
        },
        bounceSubtle: {
          '0%, 100%': { transform: 'translateY(0)' },
          '50%': { transform: 'translateY(-5px)' },
        }
      },
      spacing: {
        '18': '4.5rem',
        '88': '22rem',
        '128': '32rem',
      }
    },
  },
  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/typography'),
  ],
}

Update file resources/css/app.css:

@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

/* Custom BWA Office Rental Styles */
@layer base {
  html {
    scroll-behavior: smooth;
  }

  body {
    @apply font-sans text-bwa-gray-900 antialiased;
  }

  h1, h2, h3, h4, h5, h6 {
    @apply font-display font-semibold;
  }
}

@layer components {
  .btn-primary {
    @apply bg-bwa-primary-600 hover:bg-bwa-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-bwa-primary-500 focus:ring-offset-2;
  }

  .btn-secondary {
    @apply bg-white hover:bg-bwa-gray-50 text-bwa-gray-700 border border-bwa-gray-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-bwa-primary-500 focus:ring-offset-2;
  }

  .card {
    @apply bg-white rounded-xl shadow-sm border border-bwa-gray-200 overflow-hidden;
  }

  .form-input {
    @apply block w-full rounded-lg border-bwa-gray-300 shadow-sm focus:border-bwa-primary-500 focus:ring-bwa-primary-500 sm:text-sm;
  }

  .badge {
    @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
  }

  .badge-success {
    @apply badge bg-green-100 text-green-800;
  }

  .badge-warning {
    @apply badge bg-yellow-100 text-yellow-800;
  }

  .badge-error {
    @apply badge bg-red-100 text-red-800;
  }
}

@layer utilities {
  .text-shadow {
    text-shadow: 0 2px 4px rgba(0,0,0,0.1);
  }

  .bg-gradient-bwa {
    background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
  }

  .animate-pulse-subtle {
    animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
  }
}

Update vite.config.js buat include Tailwind processing:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
    css: {
        postcss: './postcss.config.js',
    },
});

Build assets buat development:

npm run dev

Membuat Layout Blade Template yang Responsif

Layout template adalah foundation dari semua halaman. Kita bikin layout yang fleksibel dan reusable.

Bikin file resources/views/layouts/app.blade.php:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <meta name="description" content="@yield('meta_description', 'BWA Office Rental - Sewa ruang kantor modern dan nyaman untuk kebutuhan bisnis Anda')">
    <meta name="keywords" content="@yield('meta_keywords', 'sewa kantor, office rental, ruang meeting, coworking space, BWA')">

    <title>@yield('title', 'BWA Office Rental - Sewa Kantor Modern & Nyaman')</title>

    <!-- Fonts -->
    <link rel="preconnect" href="<https://fonts.googleapis.com>">
    <link rel="preconnect" href="<https://fonts.gstatic.com>" crossorigin>
    <link href="<https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Lexend:wght@400;500;600;700&display=swap>" rel="stylesheet">

    <!-- Styles -->
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @stack('styles')
</head>
<body class="bg-bwa-gray-50 min-h-screen">
    <!-- Navigation -->
    @include('layouts.navigation')

    <!-- Page Content -->
    <main class="pb-16">
        @yield('content')
    </main>

    <!-- Footer -->
    @include('layouts.footer')

    <!-- Toast Notifications -->
    <div id="toast-container" class="fixed top-4 right-4 z-50 space-y-2"></div>

    @stack('scripts')

    <!-- Scripts -->
    <script>
        // Simple toast notification system
        window.showToast = function(message, type = 'success') {
            const toast = document.createElement('div');
            toast.className = `toast animate-slide-up ${type === 'success' ? 'bg-green-500' : 'bg-red-500'} text-white px-6 py-3 rounded-lg shadow-lg`;
            toast.textContent = message;

            document.getElementById('toast-container').appendChild(toast);

            setTimeout(() => {
                toast.remove();
            }, 5000);
        };

        // Loading state management
        window.showLoading = function() {
            document.body.classList.add('loading');
        };

        window.hideLoading = function() {
            document.body.classList.remove('loading');
        };
    </script>
</body>
</html>

Bikin navigation component di resources/views/layouts/navigation.blade.php:

<nav class="bg-white shadow-sm border-b border-bwa-gray-200 sticky top-0 z-40">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="flex justify-between items-center h-16">
            <!-- Logo -->
            <div class="flex items-center">
                <a href="{{ route('home') }}" class="flex items-center space-x-2">
                    <div class="w-8 h-8 bg-gradient-bwa rounded-lg flex items-center justify-center">
                        <span class="text-white font-bold text-sm">BWA</span>
                    </div>
                    <span class="font-display font-semibold text-xl text-bwa-gray-900">Office Rental</span>
                </a>
            </div>

            <!-- Desktop Navigation -->
            <div class="hidden md:flex items-center space-x-8">
                <a href="{{ route('home') }}" class="nav-link {{ request()->routeIs('home') ? 'text-bwa-primary-600' : 'text-bwa-gray-600 hover:text-bwa-gray-900' }} font-medium transition-colors">
                    Beranda
                </a>
                <a href="{{ route('offices.index') }}" class="nav-link {{ request()->routeIs('offices.*') ? 'text-bwa-primary-600' : 'text-bwa-gray-600 hover:text-bwa-gray-900' }} font-medium transition-colors">
                    Cari Kantor
                </a>
                <a href="{{ route('about') }}" class="nav-link {{ request()->routeIs('about') ? 'text-bwa-primary-600' : 'text-bwa-gray-600 hover:text-bwa-gray-900' }} font-medium transition-colors">
                    Tentang Kami
                </a>
                <a href="{{ route('contact') }}" class="nav-link {{ request()->routeIs('contact') ? 'text-bwa-primary-600' : 'text-bwa-gray-600 hover:text-bwa-gray-900' }} font-medium transition-colors">
                    Kontak
                </a>
            </div>

            <!-- User Menu -->
            <div class="flex items-center space-x-4">
                @auth
                    <div class="relative" x-data="{ open: false }">
                        <button @click="open = !open" class="flex items-center space-x-2 text-bwa-gray-600 hover:text-bwa-gray-900 focus:outline-none">
                            <img src="{{ auth()->user()->avatar_url }}" alt="{{ auth()->user()->name }}" class="w-8 h-8 rounded-full">
                            <span class="hidden sm:block font-medium">{{ auth()->user()->name }}</span>
                            <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
                            </svg>
                        </button>

                        <div x-show="open" @click.away="open = false" x-transition class="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-bwa-gray-200 py-1">
                            <a href="{{ route('dashboard') }}" class="block px-4 py-2 text-sm text-bwa-gray-700 hover:bg-bwa-gray-50">
                                Dashboard
                            </a>
                            <a href="{{ route('profile.edit') }}" class="block px-4 py-2 text-sm text-bwa-gray-700 hover:bg-bwa-gray-50">
                                Profil Saya
                            </a>
                            <a href="{{ route('bookings.history') }}" class="block px-4 py-2 text-sm text-bwa-gray-700 hover:bg-bwa-gray-50">
                                Riwayat Booking
                            </a>
                            <hr class="my-1">
                            <form method="POST" action="{{ route('logout') }}">
                                @csrf
                                <button type="submit" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50">
                                    Keluar
                                </button>
                            </form>
                        </div>
                    </div>
                @else
                    <a href="{{ route('login') }}" class="text-bwa-gray-600 hover:text-bwa-gray-900 font-medium">
                        Masuk
                    </a>
                    <a href="{{ route('register') }}" class="btn-primary">
                        Daftar
                    </a>
                @endauth
            </div>

            <!-- Mobile menu button -->
            <div class="md:hidden">
                <button type="button" class="text-bwa-gray-600 hover:text-bwa-gray-900 focus:outline-none" x-data="{ open: false }" @click="open = !open">
                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path x-show="!open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
                        <path x-show="open" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
                    </svg>
                </button>
            </div>
        </div>
    </div>
</nav>

Membuat Halaman Homepage dengan Daftar Kantor

Homepage adalah first impression aplikasi kita. Mari bikin homepage yang engaging dan informatif.

Bikin controller buat homepage:

php artisan make:controller Web/HomeController

Edit app/Http/Controllers/Web/HomeController.php:

<?php

namespace App\\Http\\Controllers\\Web;

use App\\Http\\Controllers\\Controller;
use App\\Models\\Office;
use App\\Models\\OfficeFacility;
use Illuminate\\Http\\Request;

class HomeController extends Controller
{
    public function index()
    {
        $featuredOffices = Office::with(['facilities'])
            ->featured()
            ->available()
            ->orderBy('rating', 'desc')
            ->take(6)
            ->get();

        $popularCities = Office::select('city')
            ->selectRaw('COUNT(*) as office_count')
            ->available()
            ->groupBy('city')
            ->orderBy('office_count', 'desc')
            ->take(8)
            ->get();

        $facilities = OfficeFacility::active()
            ->orderBy('name')
            ->get();

        $stats = [
            'total_offices' => Office::available()->count(),
            'total_bookings' => \\App\\Models\\Booking::where('status', '!=', 'cancelled')->count(),
            'happy_customers' => \\App\\Models\\User::whereHas('bookings', function($q) {
                $q->where('status', 'completed');
            })->count(),
            'cities_covered' => Office::distinct('city')->count(),
        ];

        return view('home', compact('featuredOffices', 'popularCities', 'facilities', 'stats'));
    }

    public function search(Request $request)
    {
        $query = Office::with(['facilities'])
            ->available();

        if ($request->filled('city')) {
            $query->where('city', 'like', '%' . $request->city . '%');
        }

        if ($request->filled('capacity')) {
            $query->where('capacity', '>=', $request->capacity);
        }

        if ($request->filled('max_price')) {
            $query->where('daily_price', '<=', $request->max_price);
        }

        if ($request->filled('facilities')) {
            $facilityIds = is_array($request->facilities) ? $request->facilities : [$request->facilities];
            $query->whereHas('facilities', function($q) use ($facilityIds) {
                $q->whereIn('office_facility_id', $facilityIds);
            });
        }

        $offices = $query->orderBy('rating', 'desc')
            ->paginate(12)
            ->withQueryString();

        return view('offices.index', compact('offices'));
    }
}

Bikin view homepage di resources/views/home.blade.php:

@extends('layouts.app')

@section('title', 'BWA Office Rental - Sewa Kantor Modern & Nyaman')
@section('meta_description', 'Temukan dan sewa ruang kantor modern yang sesuai kebutuhan bisnis Anda. Booking online mudah, lokasi strategis, fasilitas lengkap.')

@section('content')
<!-- Hero Section -->
<section class="relative bg-gradient-bwa overflow-hidden">
    <div class="absolute inset-0 bg-black opacity-10"></div>
    <div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
        <div class="text-center text-white">
            <h1 class="text-4xl md:text-6xl font-display font-bold mb-6 animate-fade-in">
                Temukan Kantor
                <span class="block text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-400">
                    Impian Anda
                </span>
            </h1>
            <p class="text-xl md:text-2xl mb-8 text-blue-100 max-w-3xl mx-auto animate-fade-in">
                Sewa ruang kantor modern dengan lokasi strategis, fasilitas lengkap, dan proses booking yang mudah.
            </p>

            <!-- Quick Search Form -->
            <div class="bg-white rounded-2xl p-6 md:p-8 shadow-2xl max-w-4xl mx-auto animate-slide-up">
                <form action="{{ route('offices.search') }}" method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
                    <div>
                        <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Kota</label>
                        <input type="text" name="city" placeholder="Cari kota..." class="form-input" value="{{ request('city') }}">
                    </div>
                    <div>
                        <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Kapasitas</label>
                        <select name="capacity" class="form-input">
                            <option value="">Pilih kapasitas</option>
                            <option value="5" {{ request('capacity') == '5' ? 'selected' : '' }}>5+ orang</option>
                            <option value="10" {{ request('capacity') == '10' ? 'selected' : '' }}>10+ orang</option>
                            <option value="20" {{ request('capacity') == '20' ? 'selected' : '' }}>20+ orang</option>
                            <option value="50" {{ request('capacity') == '50' ? 'selected' : '' }}>50+ orang</option>
                        </select>
                    </div>
                    <div>
                        <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Budget Harian</label>
                        <select name="max_price" class="form-input">
                            <option value="">Pilih budget</option>
                            <option value="500000" {{ request('max_price') == '500000' ? 'selected' : '' }}>< Rp 500rb</option>
                            <option value="1000000" {{ request('max_price') == '1000000' ? 'selected' : '' }}>< Rp 1jt</option>
                            <option value="2000000" {{ request('max_price') == '2000000' ? 'selected' : '' }}>< Rp 2jt</option>
                            <option value="5000000" {{ request('max_price') == '5000000' ? 'selected' : '' }}>< Rp 5jt</option>
                        </select>
                    </div>
                    <div class="flex items-end">
                        <button type="submit" class="btn-primary w-full h-11">
                            <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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
                            </svg>
                            Cari Kantor
                        </button>
                    </div>
                </form>
            </div>
        </div>
    </div>

    <!-- Background decoration -->
    <div class="absolute top-0 right-0 w-1/3 h-full opacity-20">
        <svg viewBox="0 0 404 784" fill="none" xmlns="<http://www.w3.org/2000/svg>">
            <defs>
                <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
                    <path d="M 40 0 L 0 0 0 40" fill="none" stroke="white" stroke-width="1"/>
                </pattern>
            </defs>
            <rect width="404" height="784" fill="url(#grid)" />
        </svg>
    </div>
</section>

<!-- Stats Section -->
<section class="py-16 bg-white">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="grid grid-cols-2 md:grid-cols-4 gap-8">
            <div class="text-center">
                <div class="text-3xl md:text-4xl font-bold text-bwa-primary-600 mb-2">{{ number_format($stats['total_offices']) }}+</div>
                <div class="text-bwa-gray-600 font-medium">Kantor Tersedia</div>
            </div>
            <div class="text-center">
                <div class="text-3xl md:text-4xl font-bold text-bwa-primary-600 mb-2">{{ number_format($stats['total_bookings']) }}+</div>
                <div class="text-bwa-gray-600 font-medium">Booking Sukses</div>
            </div>
            <div class="text-center">
                <div class="text-3xl md:text-4xl font-bold text-bwa-primary-600 mb-2">{{ number_format($stats['happy_customers']) }}+</div>
                <div class="text-bwa-gray-600 font-medium">Customer Puas</div>
            </div>
            <div class="text-center">
                <div class="text-3xl md:text-4xl font-bold text-bwa-primary-600 mb-2">{{ $stats['cities_covered'] }}+</div>
                <div class="text-bwa-gray-600 font-medium">Kota Terjangkau</div>
            </div>
        </div>
    </div>
</section>

<!-- Featured Offices -->
<section class="py-16 bg-bwa-gray-50">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="text-center mb-12">
            <h2 class="text-3xl md:text-4xl font-display font-bold text-bwa-gray-900 mb-4">
                Kantor Pilihan Terbaik
            </h2>
            <p class="text-xl text-bwa-gray-600 max-w-2xl mx-auto">
                Temukan kantor-kantor premium dengan rating tertinggi dan fasilitas terlengkap untuk kebutuhan bisnis Anda.
            </p>
        </div>

        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
            @foreach($featuredOffices as $office)
                <div class="card hover:shadow-xl transition-shadow duration-300 group">
                    <div class="relative overflow-hidden">
                        <img src="{{ $office->main_image }}" alt="{{ $office->name }}" class="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300">
                        <div class="absolute top-4 left-4">
                            <span class="badge bg-yellow-100 text-yellow-800">
                                <svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
                                    <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
                                </svg>
                                Featured
                            </span>
                        </div>
                        <div class="absolute top-4 right-4">
                            <span class="bg-white px-2 py-1 rounded-lg text-sm font-semibold text-bwa-gray-900">
                                {{ $office->rating }}/5.0
                            </span>
                        </div>
                    </div>

                    <div class="p-6">
                        <h3 class="font-display font-semibold text-lg mb-2 group-hover:text-bwa-primary-600 transition-colors">
                            {{ $office->name }}
                        </h3>
                        <p class="text-bwa-gray-600 text-sm mb-3">
                            📍 {{ $office->city }}, {{ $office->state }}
                        </p>
                        <p class="text-bwa-gray-600 text-sm mb-4 line-clamp-2">
                            {{ Str::limit($office->description, 100) }}
                        </p>

                        <div class="flex items-center justify-between mb-4">
                            <div class="text-sm text-bwa-gray-500">
                                👥 {{ $office->capacity }} orang
                            </div>
                            <div class="text-sm text-bwa-gray-500">
                                📝 {{ $office->total_reviews }} review
                            </div>
                        </div>

                        <div class="flex items-center justify-between">
                            <div>
                                <div class="text-2xl font-bold text-bwa-primary-600">
                                    {{ $office->formatted_daily_price }}
                                </div>
                                <div class="text-sm text-bwa-gray-500">per hari</div>
                            </div>
                            <a href="{{ route('offices.show', $office->slug) }}" class="btn-primary">
                                Lihat Detail
                            </a>
                        </div>
                    </div>
                </div>
            @endforeach
        </div>

        <div class="text-center mt-12">
            <a href="{{ route('offices.index') }}" class="btn-secondary">
                Lihat Semua Kantor
                <svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
                </svg>
            </a>
        </div>
    </div>
</section>

<!-- Popular Cities -->
<section class="py-16 bg-white">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="text-center mb-12">
            <h2 class="text-3xl md:text-4xl font-display font-bold text-bwa-gray-900 mb-4">
                Kota Populer
            </h2>
            <p class="text-xl text-bwa-gray-600">
                Jelajahi kantor-kantor terbaik di kota-kota besar Indonesia
            </p>
        </div>

        <div class="grid grid-cols-2 md:grid-cols-4 gap-6">
            @foreach($popularCities as $city)
                <a href="{{ route('offices.index', ['city' => $city->city]) }}" class="group">
                    <div class="card hover:shadow-lg transition-all duration-300 p-6 text-center group-hover:scale-105">
                        <div class="text-2xl mb-2">🏙️</div>
                        <h3 class="font-semibold text-lg text-bwa-gray-900 mb-1">{{ $city->city }}</h3>
                        <p class="text-sm text-bwa-gray-600">{{ $city->office_count }} kantor tersedia</p>
                    </div>
                </a>
            @endforeach
        </div>
    </div>
</section>

<!-- Features Section -->
<section class="py-16 bg-bwa-gray-50">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="text-center mb-12">
            <h2 class="text-3xl md:text-4xl font-display font-bold text-bwa-gray-900 mb-4">
                Kenapa Pilih BWA Office Rental?
            </h2>
        </div>

        <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
            <div class="text-center group">
                <div class="w-16 h-16 bg-bwa-primary-100 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-bwa-primary-200 transition-colors">
                    <svg class="w-8 h-8 text-bwa-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
                    </svg>
                </div>
                <h3 class="font-semibold text-lg mb-2">Booking Instan</h3>
                <p class="text-bwa-gray-600">Proses booking yang cepat dan mudah, konfirmasi langsung dalam hitungan menit.</p>
            </div>

            <div class="text-center group">
                <div class="w-16 h-16 bg-bwa-primary-100 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-bwa-primary-200 transition-colors">
                    <svg class="w-8 h-8 text-bwa-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
                    </svg>
                </div>
                <h3 class="font-semibold text-lg mb-2">Kualitas Terjamin</h3>
                <p class="text-bwa-gray-600">Semua kantor telah melalui verifikasi ketat untuk memastikan kualitas terbaik.</p>
            </div>

            <div class="text-center group">
                <div class="w-16 h-16 bg-bwa-primary-100 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:bg-bwa-primary-200 transition-colors">
                    <svg class="w-8 h-8 text-bwa-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M12 2.25a9.75 9.75 0 1 0 0 19.5 9.75 9.75 0 0 0 0-19.5Z"></path>
                    </svg>
                </div>
                <h3 class="font-semibold text-lg mb-2">Support 24/7</h3>
                <p class="text-bwa-gray-600">Tim customer service siap membantu Anda kapan saja, di mana saja.</p>
            </div>
        </div>
    </div>
</section>

@endsection

@push('scripts')
<script src="<https://unpkg.com/[email protected]/dist/cdn.min.js>" defer></script>
@endpush

Dengan setup ini, kita udah punya foundation yang solid buat frontend aplikasi. Homepage udah responsive, interactive, dan optimized buat user experience yang excellent.

Next step kita bakal lanjutin dengan halaman detail kantor, booking system, dan integreasi dengan backend API yang udah kta bikin!

Membuat Halaman Detail Kantor dan Sistem Booking: Experience yang Seamless

Sekarang kita lanjutin dengan bikin halaman-halaman yang lebih spesifik dan interaktif! Di bagian ini kita bakal create halaman detail kantor yang comprehensive, halaman checkout yang user-friendly, dan form upload bukti pembayaran yang secure.

Bagian ini adalah jantung dari user experience aplikasi kita - dimana customer bener-bener make keputusan dan melakukan transaksi. Jadi kita harus bikin sebaik mungkin!

Membuat Halaman Detail Kantor dengan Galeri Foto

Detail kantor adalah halaman yang paling crucial buat conversion. Customer perlu liat semua informasi lengkap sebelum decide buat booking.

Pertama, update controller OfficeController buat handle detail page:

<?php

namespace App\\Http\\Controllers\\Web;

use App\\Http\\Controllers\\Controller;
use App\\Models\\Office;
use App\\Models\\Booking;
use Illuminate\\Http\\Request;
use Carbon\\Carbon;

class OfficeController extends Controller
{
    public function index(Request $request)
    {
        $query = Office::with(['facilities', 'reviews' => function($q) {
            $q->approved()->latest()->take(3);
        }])->available();

        // Apply filters
        if ($request->filled('city')) {
            $query->inCity($request->city);
        }

        if ($request->filled('capacity')) {
            $query->withCapacity($request->capacity);
        }

        if ($request->filled('max_price')) {
            $query->where('daily_price', '<=', $request->max_price);
        }

        if ($request->filled('min_rating')) {
            $query->highRated($request->min_rating);
        }

        if ($request->filled('facilities')) {
            $facilityIds = is_array($request->facilities) ? $request->facilities : explode(',', $request->facilities);
            $query->whereHas('facilities', function($q) use ($facilityIds) {
                $q->whereIn('office_facilities.id', $facilityIds);
            });
        }

        // Sorting
        $sortBy = $request->get('sort', 'rating');
        switch ($sortBy) {
            case 'price_low':
                $query->orderBy('daily_price', 'asc');
                break;
            case 'price_high':
                $query->orderBy('daily_price', 'desc');
                break;
            case 'newest':
                $query->orderBy('created_at', 'desc');
                break;
            default:
                $query->orderBy('rating', 'desc');
        }

        $offices = $query->paginate(12)->withQueryString();

        // Get filter options
        $cities = Office::distinct('city')->orderBy('city')->pluck('city');
        $facilities = \\App\\Models\\OfficeFacility::active()->get();

        return view('offices.index', compact('offices', 'cities', 'facilities'));
    }

    public function show($slug)
    {
        $office = Office::with([
            'facilities',
            'reviews' => function($q) {
                $q->approved()->with('user')->latest();
            }
        ])->where('slug', $slug)->firstOrFail();

        // Check if office is available
        if (!$office->is_available) {
            abort(404, 'Office is currently not available for booking.');
        }

        // Get similar offices
        $similarOffices = Office::available()
            ->where('id', '!=', $office->id)
            ->where('city', $office->city)
            ->orderBy('rating', 'desc')
            ->take(3)
            ->get();

        // Get booking calendar data for next 30 days
        $bookingDates = $this->getBookingCalendar($office->id);

        return view('offices.show', compact('office', 'similarOffices', 'bookingDates'));
    }

    private function getBookingCalendar($officeId)
    {
        $startDate = Carbon::today();
        $endDate = Carbon::today()->addDays(60);

        $bookings = Booking::where('office_id', $officeId)
            ->where('status', '!=', 'cancelled')
            ->whereBetween('start_date', [$startDate, $endDate])
            ->get(['start_date', 'end_date']);

        $bookedDates = [];
        foreach ($bookings as $booking) {
            $current = Carbon::parse($booking->start_date);
            $end = Carbon::parse($booking->end_date);

            while ($current->lte($end)) {
                $bookedDates[] = $current->format('Y-m-d');
                $current->addDay();
            }
        }

        return array_unique($bookedDates);
    }

    public function checkAvailability(Request $request, Office $office)
    {
        $startDate = $request->start_date;
        $endDate = $request->end_date;

        if (!$startDate || !$endDate) {
            return response()->json(['available' => false, 'message' => 'Please select both start and end dates']);
        }

        $isAvailable = $office->isAvailableForDates($startDate, $endDate);

        if ($isAvailable) {
            // Calculate pricing
            $days = Carbon::parse($endDate)->diffInDays(Carbon::parse($startDate)) + 1;
            $subtotal = $office->daily_price * $days;
            $tax = $subtotal * 0.1; // 10% tax
            $total = $subtotal + $tax;

            return response()->json([
                'available' => true,
                'days' => $days,
                'subtotal' => $subtotal,
                'tax' => $tax,
                'total' => $total,
                'formatted' => [
                    'subtotal' => 'Rp ' . number_format($subtotal, 0, ',', '.'),
                    'tax' => 'Rp ' . number_format($tax, 0, ',', '.'),
                    'total' => 'Rp ' . number_format($total, 0, ',', '.'),
                ]
            ]);
        } else {
            return response()->json(['available' => false, 'message' => 'Office is not available for selected dates']);
        }
    }
}

Sekarang bikin view untuk detail office di resources/views/offices/show.blade.php:

@extends('layouts.app')

@section('title', $office->name . ' - BWA Office Rental')
@section('meta_description', Str::limit(strip_tags($office->description), 160))

@push('styles')
<link rel="stylesheet" href="<https://unpkg.com/swiper@8/swiper-bundle.min.css>" />
<style>
    .swiper-pagination-bullet-active {
        background-color: #3b82f6 !important;
    }
    .lightbox {
        display: none;
        position: fixed;
        z-index: 9999;
        left: 0;
        top: 0;
        width: 100%;
        height: 100%;
        background-color: rgba(0,0,0,0.9);
    }
    .lightbox.active {
        display: flex;
        align-items: center;
        justify-content: center;
    }
    .lightbox img {
        max-width: 90%;
        max-height: 90%;
        object-fit: contain;
    }
</style>
@endpush

@section('content')
<div class="bg-white">
    <!-- Breadcrumb -->
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
        <nav class="flex" aria-label="Breadcrumb">
            <ol class="flex items-center space-x-2 text-sm">
                <li><a href="{{ route('home') }}" class="text-bwa-gray-500 hover:text-bwa-gray-700">Beranda</a></li>
                <li><span class="text-bwa-gray-400">/</span></li>
                <li><a href="{{ route('offices.index') }}" class="text-bwa-gray-500 hover:text-bwa-gray-700">Cari Kantor</a></li>
                <li><span class="text-bwa-gray-400">/</span></li>
                <li><span class="text-bwa-gray-900 font-medium">{{ $office->name }}</span></li>
            </ol>
        </nav>
    </div>

    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
        <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
            <!-- Left Content -->
            <div class="lg:col-span-2">
                <!-- Office Images Gallery -->
                <div class="mb-8">
                    <div class="swiper office-gallery rounded-xl overflow-hidden">
                        <div class="swiper-wrapper">
                            @foreach($office->image_urls as $index => $image)
                                <div class="swiper-slide">
                                    <img src="{{ $image }}" alt="{{ $office->name }} - Photo {{ $index + 1 }}"
                                         class="w-full h-80 lg:h-96 object-cover cursor-pointer gallery-image"
                                         data-index="{{ $index }}">
                                </div>
                            @endforeach
                        </div>
                        <div class="swiper-pagination"></div>
                        <div class="swiper-button-next"></div>
                        <div class="swiper-button-prev"></div>
                    </div>

                    <!-- Thumbnail Navigation -->
                    <div class="grid grid-cols-4 gap-2 mt-4">
                        @foreach(array_slice($office->image_urls, 0, 4) as $index => $image)
                            <img src="{{ $image }}" alt="Thumbnail {{ $index + 1 }}"
                                 class="w-full h-20 object-cover rounded-lg cursor-pointer border-2 border-transparent hover:border-bwa-primary-500 transition-colors thumbnail-image {{ $index === 0 ? 'border-bwa-primary-500' : '' }}"
                                 data-index="{{ $index }}">
                        @endforeach
                    </div>
                </div>

                <!-- Office Info -->
                <div class="mb-8">
                    <div class="flex items-start justify-between mb-4">
                        <div>
                            <h1 class="text-3xl font-display font-bold text-bwa-gray-900 mb-2">{{ $office->name }}</h1>
                            <div class="flex items-center space-x-4 text-bwa-gray-600">
                                <div class="flex items-center">
                                    <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
                                    </svg>
                                    {{ $office->city }}, {{ $office->state }}
                                </div>
                                <div class="flex items-center">
                                    <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"></path>
                                    </svg>
                                    Kapasitas {{ $office->capacity }} orang
                                </div>
                                @if($office->area_size)
                                <div class="flex items-center">
                                    <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"></path>
                                    </svg>
                                    {{ $office->area_size }} m²
                                </div>
                                @endif
                            </div>
                        </div>
                        <div class="text-right">
                            @if($office->rating > 0)
                                <div class="flex items-center justify-end mb-1">
                                    <div class="flex items-center text-yellow-400 mr-2">
                                        @for($i = 1; $i <= 5; $i++)
                                            <svg class="w-4 h-4 {{ $i <= $office->rating ? 'fill-current' : 'fill-gray-300' }}" viewBox="0 0 20 20">
                                                <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
                                            </svg>
                                        @endfor
                                    </div>
                                    <span class="text-sm font-medium text-bwa-gray-700">{{ number_format($office->rating, 1) }}</span>
                                </div>
                                <div class="text-sm text-bwa-gray-500">{{ $office->total_reviews }} ulasan</div>
                            @endif
                        </div>
                    </div>
                </div>

                <!-- Description -->
                <div class="mb-8">
                    <h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Deskripsi</h2>
                    <div class="prose prose-bwa max-w-none">
                        {!! nl2br(e($office->description)) !!}
                    </div>
                </div>

                <!-- Facilities -->
                <div class="mb-8">
                    <h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Fasilitas</h2>
                    <div class="grid grid-cols-2 md:grid-cols-3 gap-4">
                        @foreach($office->facilities as $facility)
                            <div class="flex items-center space-x-3 p-3 bg-bwa-gray-50 rounded-lg">
                                <div class="w-8 h-8 bg-bwa-primary-100 rounded-lg flex items-center justify-center">
                                    <span class="text-bwa-primary-600 text-sm">
                                        @if($facility->icon)
                                            <i class="fas fa-{{ $facility->icon }}"></i>
                                        @else
                                            ✓
                                        @endif
                                    </span>
                                </div>
                                <span class="font-medium text-bwa-gray-900">{{ $facility->name }}</span>
                            </div>
                        @endforeach
                    </div>
                </div>

                <!-- Location & Contact -->
                <div class="mb-8">
                    <h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Lokasi & Kontak</h2>
                    <div class="bg-bwa-gray-50 rounded-xl p-6">
                        <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
                            <div>
                                <h3 class="font-medium text-bwa-gray-900 mb-2">Alamat</h3>
                                <p class="text-bwa-gray-600 mb-4">{{ $office->address }}</p>

                                @if($office->contact_person || $office->contact_phone)
                                    <h3 class="font-medium text-bwa-gray-900 mb-2">Kontak</h3>
                                    @if($office->contact_person)
                                        <p class="text-bwa-gray-600">{{ $office->contact_person }}</p>
                                    @endif
                                    @if($office->contact_phone)
                                        <p class="text-bwa-gray-600">{{ $office->contact_phone }}</p>
                                    @endif
                                @endif
                            </div>

                            @if($office->opening_hours)
                                <div>
                                    <h3 class="font-medium text-bwa-gray-900 mb-2">Jam Operasional</h3>
                                    <div class="space-y-1">
                                        @foreach($office->opening_hours as $day => $hours)
                                            <div class="flex justify-between text-sm">
                                                <span class="text-bwa-gray-600">{{ $day }}</span>
                                                <span class="font-medium text-bwa-gray-900">{{ $hours }}</span>
                                            </div>
                                        @endforeach
                                    </div>
                                </div>
                            @endif
                        </div>
                    </div>
                </div>

                <!-- Reviews -->
                @if($office->reviews->count() > 0)
                <div class="mb-8">
                    <h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">
                        Ulasan Customer ({{ $office->total_reviews }})
                    </h2>
                    <div class="space-y-6">
                        @foreach($office->reviews->take(5) as $review)
                            <div class="border-b border-bwa-gray-200 pb-6 last:border-b-0">
                                <div class="flex items-start space-x-4">
                                    <img src="{{ $review->user->avatar_url }}" alt="{{ $review->reviewer_name }}" class="w-10 h-10 rounded-full">
                                    <div class="flex-1">
                                        <div class="flex items-center justify-between mb-2">
                                            <h4 class="font-medium text-bwa-gray-900">{{ $review->reviewer_name }}</h4>
                                            <div class="flex items-center space-x-1 text-yellow-400">
                                                @for($i = 1; $i <= 5; $i++)
                                                    <svg class="w-4 h-4 {{ $i <= $review->rating ? 'fill-current' : 'fill-gray-300' }}" viewBox="0 0 20 20">
                                                        <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
                                                    </svg>
                                                @endfor
                                            </div>
                                        </div>
                                        @if($review->title)
                                            <h5 class="font-medium text-bwa-gray-900 mb-1">{{ $review->title }}</h5>
                                        @endif
                                        <p class="text-bwa-gray-600 mb-2">{{ $review->comment }}</p>
                                        <div class="text-sm text-bwa-gray-500">{{ $review->created_at->diffForHumans() }}</div>
                                    </div>
                                </div>
                            </div>
                        @endforeach
                    </div>

                    @if($office->reviews->count() > 5)
                        <div class="text-center mt-6">
                            <button class="btn-secondary" onclick="loadMoreReviews()">
                                Lihat Semua Ulasan
                            </button>
                        </div>
                    @endif
                </div>
                @endif
            </div>

            <!-- Right Sidebar - Booking Form -->
            <div class="lg:col-span-1">
                <div class="sticky top-24">
                    <div class="card p-6">
                        <div class="text-center mb-6">
                            <div class="text-3xl font-bold text-bwa-primary-600 mb-1">
                                {{ $office->formatted_daily_price }}
                            </div>
                            <div class="text-sm text-bwa-gray-500">per hari</div>
                        </div>

                        <!-- Booking Form -->
                        <form id="booking-form" action="{{ route('bookings.create') }}" method="GET">
                            <input type="hidden" name="office_id" value="{{ $office->id }}">

                            <div class="space-y-4">
                                <div class="grid grid-cols-2 gap-4">
                                    <div>
                                        <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Check-in</label>
                                        <input type="date" name="start_date" id="start_date"
                                               class="form-input"
                                               min="{{ date('Y-m-d', strtotime('+1 day')) }}"
                                               required>
                                    </div>
                                    <div>
                                        <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Check-out</label>
                                        <input type="date" name="end_date" id="end_date"
                                               class="form-input"
                                               min="{{ date('Y-m-d', strtotime('+2 days')) }}"
                                               required>
                                    </div>
                                </div>

                                <div>
                                    <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Jumlah Peserta</label>
                                    <select name="participant_count" class="form-input" required>
                                        @for($i = 1; $i <= $office->capacity; $i++)
                                            <option value="{{ $i }}">{{ $i }} orang</option>
                                        @endfor
                                    </select>
                                </div>

                                <!-- Price Calculation -->
                                <div id="price-calculation" class="hidden bg-bwa-gray-50 rounded-lg p-4">
                                    <div class="space-y-2 text-sm">
                                        <div class="flex justify-between">
                                            <span class="text-bwa-gray-600">Subtotal (<span id="days-count">0</span> hari)</span>
                                            <span id="subtotal-amount" class="font-medium">Rp 0</span>
                                        </div>
                                        <div class="flex justify-between">
                                            <span class="text-bwa-gray-600">Pajak (10%)</span>
                                            <span id="tax-amount" class="font-medium">Rp 0</span>
                                        </div>
                                        <hr class="my-2">
                                        <div class="flex justify-between text-lg font-bold">
                                            <span>Total</span>
                                            <span id="total-amount" class="text-bwa-primary-600">Rp 0</span>
                                        </div>
                                    </div>
                                </div>

                                <button type="submit" id="booking-button" class="btn-primary w-full" disabled>
                                    Pilih Tanggal Terlebih Dahulu
                                </button>
                            </div>
                        </form>

                        <!-- Trust Signals -->
                        <div class="mt-6 pt-6 border-t border-bwa-gray-200">
                            <div class="space-y-3 text-sm text-bwa-gray-600">
                                <div class="flex items-center">
                                    <svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
                                    </svg>
                                    Gratis pembatalan 24 jam sebelumnya
                                </div>
                                <div class="flex items-center">
                                    <svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
                                    </svg>
                                    Konfirmasi instan
                                </div>
                                <div class="flex items-center">
                                    <svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
                                    </svg>
                                    Support 24/7
                                </div>
                            </div>
                        </div>
                    </div>

                    <!-- Contact Owner -->
                    <div class="card p-6 mt-6">
                        <h3 class="font-semibold text-bwa-gray-900 mb-4">Ada Pertanyaan?</h3>
                        <p class="text-sm text-bwa-gray-600 mb-4">
                            Hubungi kami untuk informasi lebih lanjut atau konsultasi kebutuhan ruang kantor Anda.
                        </p>
                        <a href="<https://wa.me/>{{ preg_replace('/[^0-9]/', '', $office->contact_phone ?? '628123456789') }}"
                           target="_blank"
                           class="btn-secondary w-full flex items-center justify-center">
                            <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
                                <path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488"/>
                            </svg>
                            Hubungi via WhatsApp
                        </a>
                    </div>
                </div>
            </div>
        </div>

        <!-- Similar Offices -->
        @if($similarOffices->count() > 0)
        <div class="mt-16">
            <h2 class="text-2xl font-display font-bold text-bwa-gray-900 mb-8">Kantor Serupa di {{ $office->city }}</h2>
            <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
                @foreach($similarOffices as $similar)
                    <div class="card hover:shadow-lg transition-shadow duration-300">
                        <img src="{{ $similar->main_image }}" alt="{{ $similar->name }}" class="w-full h-48 object-cover">
                        <div class="p-4">
                            <h3 class="font-semibold text-lg mb-2">{{ $similar->name }}</h3>
                            <p class="text-bwa-gray-600 text-sm mb-3">{{ Str::limit($similar->description, 80) }}</p>
                            <div class="flex items-center justify-between">
                                <div class="text-lg font-bold text-bwa-primary-600">
                                    {{ $similar->formatted_daily_price }}
                                </div>
                                <a href="{{ route('offices.show', $similar->slug) }}" class="btn-primary text-sm py-1 px-3">
                                    Lihat Detail
                                </a>
                            </div>
                        </div>
                    </div>
                @endforeach
            </div>
        </div>
        @endif
    </div>
</div>

<!-- Lightbox -->
<div id="lightbox" class="lightbox" onclick="closeLightbox()">
    <img id="lightbox-image" src="" alt="">
    <button onclick="closeLightbox()" class="absolute top-4 right-4 text-white text-2xl hover:text-gray-300">
        ×
    </button>
</div>

@endsection

@push('scripts')
<script src="<https://unpkg.com/swiper@8/swiper-bundle.min.js>"></script>
<script src="<https://unpkg.com/[email protected]/dist/cdn.min.js>" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
    // Initialize Swiper
    const swiper = new Swiper('.office-gallery', {
        slidesPerView: 1,
        spaceBetween: 0,
        loop: true,
        pagination: {
            el: '.swiper-pagination',
            clickable: true,
        },
        navigation: {
            nextEl: '.swiper-button-next',
            prevEl: '.swiper-button-prev',
        },
    });

    // Gallery lightbox
    const galleryImages = document.querySelectorAll('.gallery-image');
    const lightbox = document.getElementById('lightbox');
    const lightboxImage = document.getElementById('lightbox-image');

    galleryImages.forEach((img, index) => {
        img.addEventListener('click', () => {
            lightboxImage.src = img.src;
            lightbox.classList.add('active');
        });
    });

    // Thumbnail navigation
    const thumbnails = document.querySelectorAll('.thumbnail-image');
    thumbnails.forEach((thumb, index) => {
        thumb.addEventListener('click', () => {
            swiper.slideTo(index);
            thumbnails.forEach(t => t.classList.remove('border-bwa-primary-500'));
            thumb.classList.add('border-bwa-primary-500');
        });
    });

    // Booking form functionality
    const startDateInput = document.getElementById('start_date');
    const endDateInput = document.getElementById('end_date');
    const bookingButton = document.getElementById('booking-button');
    const priceCalculation = document.getElementById('price-calculation');

    const bookedDates = @json($bookingDates);

    function updateDateConstraints() {
        const startDate = startDateInput.value;
        if (startDate) {
            const minEndDate = new Date(startDate);
            minEndDate.setDate(minEndDate.getDate() + 1);
            endDateInput.min = minEndDate.toISOString().split('T')[0];
        }
    }

    function checkAvailability() {
        const startDate = startDateInput.value;
        const endDate = endDateInput.value;

        if (!startDate || !endDate) {
            bookingButton.disabled = true;
            bookingButton.textContent = 'Pilih Tanggal Terlebih Dahulu';
            priceCalculation.classList.add('hidden');
            return;
        }

        // Check if dates are booked
        const start = new Date(startDate);
        const end = new Date(endDate);
        let isBooked = false;

        for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
            const dateStr = d.toISOString().split('T')[0];
            if (bookedDates.includes(dateStr)) {
                isBooked = true;
                break;
            }
        }

        if (isBooked) {
            bookingButton.disabled = true;
            bookingButton.textContent = 'Tanggal Tidak Tersedia';
            bookingButton.classList.add('bg-red-500', 'hover:bg-red-600');
            bookingButton.classList.remove('bg-bwa-primary-600', 'hover:bg-bwa-primary-700');
            priceCalculation.classList.add('hidden');
            return;
        }

        // Calculate price
        fetch(`{{ route('offices.check-availability', $office->id) }}?start_date=${startDate}&end_date=${endDate}`)
            .then(response => response.json())
            .then(data => {
                if (data.available) {
                    document.getElementById('days-count').textContent = data.days;
                    document.getElementById('subtotal-amount').textContent = data.formatted.subtotal;
                    document.getElementById('tax-amount').textContent = data.formatted.tax;
                    document.getElementById('total-amount').textContent = data.formatted.total;

                    priceCalculation.classList.remove('hidden');
                    bookingButton.disabled = false;
                    bookingButton.textContent = 'Lanjutkan Booking';
                    bookingButton.classList.remove('bg-red-500', 'hover:bg-red-600');
                    bookingButton.classList.add('bg-bwa-primary-600', 'hover:bg-bwa-primary-700');
                } else {
                    bookingButton.disabled = true;
                    bookingButton.textContent = data.message || 'Tidak Tersedia';
                    priceCalculation.classList.add('hidden');
                }
            })
            .catch(error => {
                console.error('Error:', error);
                bookingButton.disabled = true;
                bookingButton.textContent = 'Error memeriksa ketersediaan';
            });
    }

    startDateInput.addEventListener('change', () => {
        updateDateConstraints();
        checkAvailability();
    });

    endDateInput.addEventListener('change', checkAvailability);

    // Disable booked dates
    function disableBookedDates() {
        const today = new Date();
        const maxDate = new Date();
        maxDate.setDate(maxDate.getDate() + 365); // 1 year ahead

        startDateInput.setAttribute('max', maxDate.toISOString().split('T')[0]);
        endDateInput.setAttribute('max', maxDate.toISOString().split('T')[0]);
    }

    disableBookedDates();
});

function closeLightbox() {
    document.getElementById('lightbox').classList.remove('active');
}

function loadMoreReviews() {
    // Implementation untuk load more reviews via AJAX
    console.log('Loading more reviews...');
}
</script>
@endpush

Membuat Halaman Checkout dengan Form Booking

Sekarang kita bikin halaman checkout yang comprehensive dengan form booking yang user-friendly.

Bikin controller untuk booking:

php artisan make:controller Web/BookingController

Edit app/Http/Controllers/Web/BookingController.php:

<?php

namespace App\\Http\\Controllers\\Web;

use App\\Http\\Controllers\\Controller;
use App\\Models\\Office;
use App\\Models\\Booking;
use App\\Models\\Payment;
use Illuminate\\Http\\Request;
use Carbon\\Carbon;
use Illuminate\\Support\\Facades\\Auth;
use Illuminate\\Support\\Facades\\DB;

class BookingController extends Controller
{
    public function create(Request $request)
    {
        $request->validate([
            'office_id' => 'required|exists:offices,id',
            'start_date' => 'required|date|after:today',
            'end_date' => 'required|date|after:start_date',
            'participant_count' => 'required|integer|min:1',
        ]);

        $office = Office::findOrFail($request->office_id);

        // Verify office is available
        if (!$office->is_available) {
            return redirect()->back()->with('error', 'Kantor ini sedang tidak tersedia untuk booking.');
        }

        // Check participant count
        if ($request->participant_count > $office->capacity) {
            return redirect()->back()->with('error', 'Jumlah peserta melebihi kapasitas kantor.');
        }

        // Check date availability
        if (!$office->isAvailableForDates($request->start_date, $request->end_date)) {
            return redirect()->back()->with('error', 'Kantor tidak tersedia untuk tanggal yang dipilih.');
        }

        // Calculate pricing
        $startDate = Carbon::parse($request->start_date);
        $endDate = Carbon::parse($request->end_date);
        $totalDays = $endDate->diffInDays($startDate) + 1;

        $subtotal = $office->daily_price * $totalDays;
        $tax = $subtotal * 0.1; // 10% tax
        $total = $subtotal + $tax;

        $bookingData = [
            'office' => $office,
            'start_date' => $startDate,
            'end_date' => $endDate,
            'participant_count' => $request->participant_count,
            'total_days' => $totalDays,
            'subtotal' => $subtotal,
            'tax' => $tax,
            'total' => $total,
            'purpose' => $request->purpose,
        ];

        return view('bookings.create', $bookingData);
    }

    public function store(Request $request)
    {
        $request->validate([
            'office_id' => 'required|exists:offices,id',
            'start_date' => 'required|date|after:today',
            'end_date' => 'required|date|after:start_date',
            'participant_count' => 'required|integer|min:1',
            'purpose' => 'nullable|string|max:255',
            'special_requests' => 'nullable|string|max:1000',
            'duration_type' => 'required|in:hourly,daily,monthly',
        ]);

        $office = Office::findOrFail($request->office_id);

        // Double check availability
        if (!$office->isAvailableForDates($request->start_date, $request->end_date)) {
            return redirect()->back()->with('error', 'Kantor tidak tersedia untuk tanggal yang dipilih.');
        }

        DB::beginTransaction();

        try {
            // Calculate pricing
            $startDate = Carbon::parse($request->start_date);
            $endDate = Carbon::parse($request->end_date);
            $totalDays = $endDate->diffInDays($startDate) + 1;

            $subtotal = $office->daily_price * $totalDays;
            $tax = $subtotal * 0.1;
            $total = $subtotal + $tax;

            // Create booking
            $booking = Booking::create([
                'booking_code' => 'BWA' . strtoupper(uniqid()),
                'user_id' => Auth::id(),
                'office_id' => $office->id,
                'start_date' => $startDate,
                'end_date' => $endDate,
                'duration_type' => $request->duration_type,
                'total_days' => $totalDays,
                'participant_count' => $request->participant_count,
                'purpose' => $request->purpose,
                'special_requests' => $request->special_requests,
                'subtotal' => $subtotal,
                'tax_amount' => $tax,
                'total_amount' => $total,
                'status' => 'pending',
            ]);

            // Create payment record
            $payment = Payment::create([
                'booking_id' => $booking->id,
                'payment_method' => 'bank_transfer', // default for now
                'amount' => $total,
                'status' => 'pending',
            ]);

            DB::commit();

            return redirect()->route('bookings.payment', $booking->booking_code)
                ->with('success', 'Booking berhasil dibuat! Silakan lakukan pembayaran.');

        } catch (\\Exception $e) {
            DB::rollback();
            return redirect()->back()->with('error', 'Terjadi kesalahan. Silakan coba lagi.');
        }
    }

    public function payment($bookingCode)
    {
        $booking = Booking::with(['office', 'payment'])
            ->where('booking_code', $bookingCode)
            ->where('user_id', Auth::id())
            ->firstOrFail();

        if ($booking->status !== 'pending') {
            return redirect()->route('dashboard')->with('info', 'Booking ini sudah diproses.');
        }

        return view('bookings.payment', compact('booking'));
    }

    public function uploadPayment(Request $request, $bookingCode)
    {
        $request->validate([
            'payment_proof' => 'required|image|mimes:jpeg,png,jpg,pdf|max:2048',
            'payment_date' => 'required|date',
            'reference_number' => 'nullable|string|max:100',
            'notes' => 'nullable|string|max:500',
        ]);

        $booking = Booking::with('payment')
            ->where('booking_code', $bookingCode)
            ->where('user_id', Auth::id())
            ->firstOrFail();

        if ($booking->payment->status !== 'pending') {
            return redirect()->back()->with('error', 'Pembayaran sudah diproses sebelumnya.');
        }

        // Upload payment proof
        $proofPath = $request->file('payment_proof')->store('payment-proofs', 'public');

        // Update payment record
        $booking->payment->update([
            'payment_proof' => $proofPath,
            'payment_date' => $request->payment_date,
            'reference_number' => $request->reference_number,
            'notes' => $request->notes,
            'status' => 'pending', // still pending until admin verification
        ]);

        return redirect()->route('dashboard')
            ->with('success', 'Bukti pembayaran berhasil diunggah. Kami akan memverifikasi dalam 1x24 jam.');
    }
}

Bikin view untuk halaman checkout di resources/views/bookings/create.blade.php:

@extends('layouts.app')

@section('title', 'Checkout Booking - BWA Office Rental')

@section('content')
<div class="min-h-screen bg-bwa-gray-50 py-8">
    <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
        <!-- Progress Indicator -->
        <div class="mb-8">
            <div class="flex items-center justify-center">
                <div class="flex items-center space-x-4">
                    <div class="flex items-center">
                        <div class="w-8 h-8 bg-bwa-primary-600 text-white rounded-full flex items-center justify-center text-sm font-medium">1</div>
                        <span class="ml-2 text-sm font-medium text-bwa-primary-600">Detail Booking</span>
                    </div>
                    <div class="w-12 h-0.5 bg-bwa-gray-300"></div>
                    <div class="flex items-center">
                        <div class="w-8 h-8 bg-bwa-gray-300 text-bwa-gray-600 rounded-full flex items-center justify-center text-sm font-medium">2</div>
                        <span class="ml-2 text-sm font-medium text-bwa-gray-600">Pembayaran</span>
                    </div>
                    <div class="w-12 h-0.5 bg-bwa-gray-300"></div>
                    <div class="flex items-center">
                        <div class="w-8 h-8 bg-bwa-gray-300 text-bwa-gray-600 rounded-full flex items-center justify-center text-sm font-medium">3</div>
                        <span class="ml-2 text-sm font-medium text-bwa-gray-600">Konfirmasi</span>
                    </div>
                </div>
            </div>
        </div>

        <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
            <!-- Booking Form -->
            <div class="lg:col-span-2">
                <div class="card p-6">
                    <h1 class="text-2xl font-display font-bold text-bwa-gray-900 mb-6">Detail Booking</h1>

                    <form action="{{ route('bookings.store') }}" method="POST" id="booking-form">
                        @csrf
                        <input type="hidden" name="office_id" value="{{ $office->id }}">
                        <input type="hidden" name="start_date" value="{{ $start_date->format('Y-m-d') }}">
                        <input type="hidden" name="end_date" value="{{ $end_date->format('Y-m-d') }}">
                        <input type="hidden" name="participant_count" value="{{ $participant_count }}">
                        <input type="hidden" name="duration_type" value="daily">

                        <!-- Personal Information -->
                        <div class="mb-8">
                            <h2 class="text-lg font-semibold text-bwa-gray-900 mb-4">Informasi Pemesan</h2>
                            <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                                <div>
                                    <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Nama Lengkap</label>
                                    <input type="text" value="{{ auth()->user()->name }}" class="form-input bg-bwa-gray-50" readonly>
                                </div>
                                <div>
                                    <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Email</label>
                                    <input type="email" value="{{ auth()->user()->email }}" class="form-input bg-bwa-gray-50" readonly>
                                </div>
                                <div>
                                    <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Nomor Telepon</label>
                                    <input type="tel" value="{{ auth()->user()->phone }}" class="form-input bg-bwa-gray-50" readonly>
                                </div>
                                <div>
                                    <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Jumlah Peserta</label>
                                    <input type="number" value="{{ $participant_count }}" class="form-input bg-bwa-gray-50" readonly>
                                </div>
                            </div>
                        </div>

                        <!-- Booking Details -->
                        <div class="mb-8">
                            <h2 class="text-lg font-semibold text-bwa-gray-900 mb-4">Detail Acara</h2>
                            <div class="space-y-4">
                                <div>
                                    <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Tujuan Booking</label>
                                    <select name="purpose" class="form-input" required>
                                        <option value="">Pilih tujuan booking</option>
                                        <option value="meeting">Meeting/Rapat</option>
                                        <option value="workshop">Workshop/Training</option>
                                        <option value="seminar">Seminar/Presentasi</option>
                                        <option value="coworking">Coworking</option>
                                        <option value="event">Event/Acara</option>
                                        <option value="other">Lainnya</option>
                                    </select>
                                </div>

                                <div>
                                    <label class="block text-sm font-medium text-bwa-gray-700 mb-2">Permintaan Khusus (Opsional)</label>
                                    <textarea name="special_requests" rows="3" class="form-input"
                                              placeholder="Contoh: Butuh proyektor tambahan, catering halal, akses parkir untuk 5 mobil, dll."></textarea>
                                </div>
                            </div>
                        </div>

                        <!-- Terms and Conditions -->
                        <div class="mb-6">
                            <div class="flex items-start">
                                <input type="checkbox" id="terms" required class="mt-1 h-4 w-4 text-bwa-primary-600 focus:ring-bwa-primary-500 border-bwa-gray-300 rounded">
                                <label for="terms" class="ml-2 text-sm text-bwa-gray-600">
                                    Saya setuju dengan <a href="#" class="text-bwa-primary-600 hover:underline">syarat dan ketentuan</a> serta
                                    <a href="#" class="text-bwa-primary-600 hover:underline">kebijakan privasi</a> BWA Office Rental.
                                </label>
                            </div>
                        </div>

                        <div class="flex items-center justify-between">
                            <a href="{{ route('offices.show', $office->slug) }}" class="btn-secondary">
                                <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
                                </svg>
                                Kembali
                            </a>
                            <button type="submit" class="btn-primary">
                                Lanjutkan ke Pembayaran
                                <svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
                                </svg>
                            </button>
                        </div>
                    </form>
                </div>
            </div>

            <!-- Booking Summary -->
            <div class="lg:col-span-1">
                <div class="sticky top-24">
                    <div class="card p-6">
                        <h2 class="text-lg font-semibold text-bwa-gray-900 mb-4">Ringkasan Booking</h2>

                        <!-- Office Info -->
                        <div class="flex items-start space-x-3 mb-6">
                            <img src="{{ $office->main_image }}" alt="{{ $office->name }}" class="w-16 h-16 rounded-lg object-cover">
                            <div class="flex-1">
                                <h3 class="font-medium text-bwa-gray-900">{{ $office->name }}</h3>
                                <p class="text-sm text-bwa-gray-600">{{ $office->city }}, {{ $office->state }}</p>
                                <div class="flex items-center mt-1">
                                    <svg class="w-4 h-4 text-yellow-400 mr-1" fill="currentColor" viewBox="0 0 20 20">
                                        <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
                                    </svg>
                                    <span class="text-sm text-bwa-gray-600">{{ $office->rating }}/5.0</span>
                                </div>
                            </div>
                        </div>

                        <!-- Booking Details -->
                        <div class="space-y-3 mb-6">
                            <div class="flex justify-between text-sm">
                                <span class="text-bwa-gray-600">Check-in</span>
                                <span class="font-medium">{{ $start_date->format('d M Y') }}</span>
                            </div>
                            <div class="flex justify-between text-sm">
                                <span class="text-bwa-gray-600">Check-out</span>
                                <span class="font-medium">{{ $end_date->format('d M Y') }}</span>
                            </div>
                            <div class="flex justify-between text-sm">
                                <span class="text-bwa-gray-600">Durasi</span>
                                <span class="font-medium">{{ $total_days }} hari</span>
                            </div>
                            <div class="flex justify-between text-sm">
                                <span class="text-bwa-gray-600">Peserta</span>
                                <span class="font-medium">{{ $participant_count }} orang</span>
                            </div>
                        </div>

                        <!-- Price Breakdown -->
                        <div class="border-t border-bwa-gray-200 pt-4">
                            <div class="space-y-2 mb-4">
                                <div class="flex justify-between text-sm">
                                    <span class="text-bwa-gray-600">{{ $office->formatted_daily_price }} × {{ $total_days }} hari</span>
                                    <span class="font-medium">Rp {{ number_format($subtotal, 0, ',', '.') }}</span>
                                </div>
                                <div class="flex justify-between text-sm">
                                    <span class="text-bwa-gray-600">Pajak (10%)</span>
                                    <span class="font-medium">Rp {{ number_format($tax, 0, ',', '.') }}</span>
                                </div>
                            </div>
                            <div class="border-t border-bwa-gray-200 pt-4">
                                <div class="flex justify-between text-lg font-bold">
                                    <span>Total</span>
                                    <span class="text-bwa-primary-600">Rp {{ number_format($total, 0, ',', '.') }}</span>
                                </div>
                            </div>
                        </div>

                        <!-- Trust Signals -->
                        <div class="mt-6 pt-4 border-t border-bwa-gray-200">
                            <div class="space-y-2 text-xs text-bwa-gray-600">
                                <div class="flex items-center">
                                    <svg class="w-3 h-3 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
                                    </svg>
                                    Customer support 24/7
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
    const form = document.getElementById('booking-form');
    const submitButton = form.querySelector('button[type="submit"]');

    form.addEventListener('submit', function(e) {
        submitButton.disabled = true;
        submitButton.innerHTML = `
            <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="<http://www.w3.org/2000/svg>" fill="none" viewBox="0 0 24 24">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
            </svg>
            Memproses...
        `;
    });
});
</script>
@endpush

Membuat Form Upload Bukti Pembayaran

Sekarang kita bikin halaman payment dengan form upload bukti pembayaran yang secure dan user-friendly.

Bikin view untuk halaman payment di resources/views/bookings/payment.blade.php:

@extends('layouts.app')

@section('title', 'Pembayaran - BWA Office Rental')

@section('content')
<div class="min-h-screen bg-bwa-gray-50 py-8">
    <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
        <!-- Progress Indicator -->
        <div class="mb-8">
            <div class="flex items-center justify-center">
                <div class="flex items-center space-x-4">
                    <div class="flex items-center">
                        <div class="w-8 h-8 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-medium">
                            <svg class="w-4 h-4" 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>
                        <span class="ml-2 text-sm font-medium text-green-600">Detail Booking</span>
                    </div>
                    <div class="w-12 h-0.5 bg-bwa-primary-600"></div>
                    <div class="flex items-center">
                        <div class="w-8 h-8 bg-bwa-primary-600 text-white rounded-full flex items-center justify-center text-sm font-medium">2</div>
                        <span class="ml-2 text-sm font-medium text-bwa-primary-600">Pembayaran</span>
                    </div>
                    <div class="w-12 h-0.5 bg-bwa-gray-300"></div>
                    <div class="flex items-center">
                        <div class="w-8 h-8 bg-bwa-gray-300 text-bwa-gray-600 rounded-full flex items-center justify-center text-sm font-medium">3</div>
                        <span class="ml-2 text-sm font-medium text-bwa-gray-600">Konfirmasi</span>
                    </div>
                </div>
            </div>
        </div>

        <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
            <!-- Payment Instructions -->
            <div class="lg:col-span-2 space-y-6">
                <!-- Booking Success Alert -->
                <div class="bg-green-50 border border-green-200 rounded-lg p-4">
                    <div class="flex">
                        <svg class="w-5 h-5 text-green-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
                        </svg>
                        <div class="ml-3">
                            <h3 class="text-sm font-medium text-green-800">Booking Berhasil Dibuat!</h3>
                            <div class="mt-1 text-sm text-green-700">
                                <p>Kode booking Anda: <strong>{{ $booking->booking_code }}</strong></p>
                                <p class="mt-1">Silakan lakukan pembayaran sebelum <strong>{{ $booking->created_at->addHours(24)->format('d M Y, H:i') }} WIB</strong></p>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- Payment Instructions -->
                <div class="card p-6">
                    <h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Instruksi Pembayaran</h2>

                    <!-- Bank Transfer Options -->
                    <div class="space-y-4 mb-6">
                        <div class="border border-bwa-gray-200 rounded-lg p-4">
                            <div class="flex items-center justify-between mb-3">
                                <div class="flex items-center">
                                    <div class="w-12 h-8 bg-blue-600 rounded flex items-center justify-center">
                                        <span class="text-white text-xs font-bold">BCA</span>
                                    </div>
                                    <div class="ml-3">
                                        <div class="font-medium text-bwa-gray-900">Bank Central Asia</div>
                                        <div class="text-sm text-bwa-gray-600">Transfer via ATM, Mobile Banking, atau Internet Banking</div>
                                    </div>
                                </div>
                            </div>
                            <div class="bg-bwa-gray-50 rounded-lg p-3">
                                <div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
                                    <div>
                                        <span class="text-bwa-gray-600">Nomor Rekening:</span>
                                        <br>
                                        <span class="font-mono font-bold text-lg">1234567890</span>
                                        <button onclick="copyToClipboard('1234567890')" class="ml-2 text-bwa-primary-600 hover:text-bwa-primary-700">
                                            <svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
                                            </svg>
                                        </button>
                                    </div>
                                    <div>
                                        <span class="text-bwa-gray-600">Atas Nama:</span>
                                        <br>
                                        <span class="font-medium">PT. Build With Angga Office Rental</span>
                                    </div>
                                </div>
                            </div>
                        </div>

                        <div class="border border-bwa-gray-200 rounded-lg p-4">
                            <div class="flex items-center justify-between mb-3">
                                <div class="flex items-center">
                                    <div class="w-12 h-8 bg-red-600 rounded flex items-center justify-center">
                                        <span class="text-white text-xs font-bold">BNI</span>
                                    </div>
                                    <div class="ml-3">
                                        <div class="font-medium text-bwa-gray-900">Bank Negara Indonesia</div>
                                        <div class="text-sm text-bwa-gray-600">Transfer via ATM, Mobile Banking, atau Internet Banking</div>
                                    </div>
                                </div>
                            </div>
                            <div class="bg-bwa-gray-50 rounded-lg p-3">
                                <div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
                                    <div>
                                        <span class="text-bwa-gray-600">Nomor Rekening:</span>
                                        <br>
                                        <span class="font-mono font-bold text-lg">0987654321</span>
                                        <button onclick="copyToClipboard('0987654321')" class="ml-2 text-bwa-primary-600 hover:text-bwa-primary-700">
                                            <svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
                                            </svg>
                                        </button>
                                    </div>
                                    <div>
                                        <span class="text-bwa-gray-600">Atas Nama:</span>
                                        <br>
                                        <span class="font-medium">PT. Build With Angga Office Rental</span>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>

                    <!-- Important Notes -->
                    <div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
                        <div class="flex">
                            <svg class="w-5 h-5 text-yellow-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16c-.77.833.192 2.5 1.732 2.5z"></path>
                            </svg>
                            <div class="ml-3">
                                <h3 class="text-sm font-medium text-yellow-800">Penting!</h3>
                                <div class="mt-1 text-sm text-yellow-700">
                                    <ul class="list-disc list-inside space-y-1">
                                        <li>Transfer dengan jumlah <strong>EXACT</strong>: Rp {{ number_format($booking->total_amount, 0, ',', '.') }}</li>
                                        <li>Simpan bukti transfer dan upload di form dibawah</li>
                                        <li>Pembayaran akan diverifikasi dalam 1x24 jam</li>
                                        <li>Booking otomatis dibatalkan jika tidak ada pembayaran dalam 24 jam</li>
                                    </ul>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- Upload Payment Proof Form -->
                <div class="card p-6">
                    <h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Upload Bukti Pembayaran</h2>

                    <form action="{{ route('bookings.upload-payment', $booking->booking_code) }}" method="POST" enctype="multipart/form-data" id="payment-form">
                        @csrf

                        <div class="space-y-6">
                            <!-- Payment Date -->
                            <div>
                                <label class="block text-sm font-medium text-bwa-gray-700 mb-2">
                                    Tanggal Pembayaran <span class="text-red-500">*</span>
                                </label>
                                <input type="date" name="payment_date" class="form-input"
                                       max="{{ date('Y-m-d') }}"
                                       value="{{ date('Y-m-d') }}"
                                       required>
                            </div>

                            <!-- Reference Number -->
                            <div>
                                <label class="block text-sm font-medium text-bwa-gray-700 mb-2">
                                    Nomor Referensi/Transaction ID (Opsional)
                                </label>
                                <input type="text" name="reference_number" class="form-input"
                                       placeholder="Contoh: TRF123456789">
                                <p class="mt-1 text-sm text-bwa-gray-500">Nomor referensi dari bank atau e-wallet (jika ada)</p>
                            </div>

                            <!-- Payment Proof Upload -->
                            <div>
                                <label class="block text-sm font-medium text-bwa-gray-700 mb-2">
                                    Bukti Pembayaran <span class="text-red-500">*</span>
                                </label>
                                <div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-bwa-gray-300 border-dashed rounded-lg hover:border-bwa-primary-400 transition-colors"
                                     id="drop-zone">
                                    <div class="space-y-1 text-center">
                                        <svg class="mx-auto h-12 w-12 text-bwa-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
                                            <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
                                        </svg>
                                        <div class="flex text-sm text-bwa-gray-600">
                                            <label for="payment_proof" class="relative cursor-pointer bg-white rounded-md font-medium text-bwa-primary-600 hover:text-bwa-primary-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-bwa-primary-500">
                                                <span>Upload file</span>
                                                <input id="payment_proof" name="payment_proof" type="file" class="sr-only"
                                                       accept="image/*,.pdf" required>
                                            </label>
                                            <p class="pl-1">atau drag and drop</p>
                                        </div>
                                        <p class="text-xs text-bwa-gray-500">PNG, JPG, PDF hingga 2MB</p>
                                    </div>
                                </div>

                                <!-- File Preview -->
                                <div id="file-preview" class="hidden mt-4">
                                    <div class="flex items-center p-3 bg-bwa-gray-50 rounded-lg">
                                        <svg class="w-8 h-8 text-bwa-primary-600 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
                                        </svg>
                                        <div class="flex-1">
                                            <div id="file-name" class="text-sm font-medium text-bwa-gray-900"></div>
                                            <div id="file-size" class="text-xs text-bwa-gray-500"></div>
                                        </div>
                                        <button type="button" onclick="removeFile()" class="text-red-500 hover:text-red-700">
                                            <svg class="w-5 h-5" 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>
                                        </button>
                                    </div>
                                </div>
                            </div>

                            <!-- Additional Notes -->
                            <div>
                                <label class="block text-sm font-medium text-bwa-gray-700 mb-2">
                                    Catatan Tambahan (Opsional)
                                </label>
                                <textarea name="notes" rows="3" class="form-input"
                                          placeholder="Contoh: Transfer dari rekening atas nama berbeda, ada potongan biaya admin, dll."></textarea>
                            </div>

                            <!-- Submit Button -->
                            <div class="flex items-center justify-between pt-4">
                                <a href="{{ route('dashboard') }}" class="btn-secondary">
                                    Nanti Saja
                                </a>
                                <button type="submit" class="btn-primary" id="submit-btn">
                                    Upload Bukti Pembayaran
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>

            <!-- Booking Summary -->
            <div class="lg:col-span-1">
                <div class="sticky top-24">
                    <div class="card p-6">
                        <h2 class="text-lg font-semibold text-bwa-gray-900 mb-4">Detail Booking</h2>

                        <!-- Booking Code -->
                        <div class="bg-bwa-primary-50 rounded-lg p-3 mb-4">
                            <div class="text-center">
                                <div class="text-sm text-bwa-primary-600 font-medium">Kode Booking</div>
                                <div class="text-xl font-bold text-bwa-primary-900">{{ $booking->booking_code }}</div>
                            </div>
                        </div>

                        <!-- Office Info -->
                        <div class="flex items-start space-x-3 mb-4">
                            <img src="{{ $booking->office->main_image }}" alt="{{ $booking->office->name }}" class="w-12 h-12 rounded-lg object-cover">
                            <div class="flex-1">
                                <h3 class="font-medium text-bwa-gray-900 text-sm">{{ $booking->office->name }}</h3>
                                <p class="text-xs text-bwa-gray-600">{{ $booking->office->city }}, {{ $booking->office->state }}</p>
                            </div>
                        </div>

                        <!-- Booking Details -->
                        <div class="space-y-2 mb-4 text-sm">
                            <div class="flex justify-between">
                                <span class="text-bwa-gray-600">Check-in</span>
                                <span class="font-medium">{{ $booking->start_date->format('d M Y') }}</span>
                            </div>
                            <div class="flex justify-between">
                                <span class="text-bwa-gray-600">Check-out</span>
                                <span class="font-medium">{{ $booking->end_date->format('d M Y') }}</span>
                            </div>
                            <div class="flex justify-between">
                                <span class="text-bwa-gray-600">Durasi</span>
                                <span class="font-medium">{{ $booking->total_days }} hari</span>
                            </div>
                            <div class="flex justify-between">
                                <span class="text-bwa-gray-600">Peserta</span>
                                <span class="font-medium">{{ $booking->participant_count }} orang</span>
                            </div>
                        </div>

                        <!-- Price Breakdown -->
                        <div class="border-t border-bwa-gray-200 pt-4">
                            <div class="space-y-2 mb-3 text-sm">
                                <div class="flex justify-between">
                                    <span class="text-bwa-gray-600">Subtotal</span>
                                    <span class="font-medium">{{ $booking->formatted_subtotal }}</span>
                                </div>
                                <div class="flex justify-between">
                                    <span class="text-bwa-gray-600">Pajak (10%)</span>
                                    <span class="font-medium">Rp {{ number_format($booking->tax_amount, 0, ',', '.') }}</span>
                                </div>
                            </div>
                            <div class="border-t border-bwa-gray-200 pt-3">
                                <div class="flex justify-between text-lg font-bold">
                                    <span>Total Bayar</span>
                                    <span class="text-bwa-primary-600">{{ $booking->formatted_total_amount }}</span>
                                </div>
                            </div>
                        </div>

                        <!-- Payment Deadline -->
                        <div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
                            <div class="text-center">
                                <div class="text-sm text-red-600 font-medium">Batas Waktu Pembayaran</div>
                                <div class="text-sm font-bold text-red-900" id="countdown">
                                    {{ $booking->created_at->addHours(24)->format('d M Y, H:i') }} WIB
                                </div>
                            </div>
                        </div>

                        <!-- Help -->
                        <div class="mt-4 text-center">
                            <p class="text-sm text-bwa-gray-600 mb-2">Butuh bantuan?</p>
                            <a href="<https://wa.me/628123456789>" target="_blank" class="btn-secondary text-sm py-2 px-4">
                                <svg class="w-4 h-4 mr-1 inline" fill="currentColor" viewBox="0 0 24 24">
                                    <path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488"/>
                                </svg>
                                WhatsApp
                            </a>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
    const dropZone = document.getElementById('drop-zone');
    const fileInput = document.getElementById('payment_proof');
    const filePreview = document.getElementById('file-preview');
    const paymentForm = document.getElementById('payment-form');
    const submitBtn = document.getElementById('submit-btn');

    // Drag and drop functionality
    ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
        dropZone.addEventListener(eventName, preventDefaults, false);
    });

    ['dragenter', 'dragover'].forEach(eventName => {
        dropZone.addEventListener(eventName, highlight, false);
    });

    ['dragleave', 'drop'].forEach(eventName => {
        dropZone.addEventListener(eventName, unhighlight, false);
    });

    dropZone.addEventListener('drop', handleDrop, false);
    fileInput.addEventListener('change', handleFileSelect, false);

    function preventDefaults(e) {
        e.preventDefault();
        e.stopPropagation();
    }

    function highlight(e) {
        dropZone.classList.add('border-bwa-primary-500', 'bg-bwa-primary-50');
    }

    function unhighlight(e) {
        dropZone.classList.remove('border-bwa-primary-500', 'bg-bwa-primary-50');
    }

    function handleDrop(e) {
        const dt = e.dataTransfer;
        const files = dt.files;

        if (files.length > 0) {
            fileInput.files = files;
            handleFileSelect({ target: { files: files } });
        }
    }

    function handleFileSelect(e) {
        const files = e.target.files;
        if (files.length > 0) {
            const file = files[0];

            // Validate file type
            const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'application/pdf'];
            if (!allowedTypes.includes(file.type)) {
                alert('Hanya file JPG, PNG, atau PDF yang diperbolehkan.');
                return;
            }

            // Validate file size (2MB)
            if (file.size > 2 * 1024 * 1024) {
                alert('Ukuran file maksimal 2MB.');
                return;
            }

            // Show file preview
            document.getElementById('file-name').textContent = file.name;
            document.getElementById('file-size').textContent = formatFileSize(file.size);
            filePreview.classList.remove('hidden');
            dropZone.style.display = 'none';
        }
    }

    // Form submission
    paymentForm.addEventListener('submit', function(e) {
        submitBtn.disabled = true;
        submitBtn.innerHTML = `
            <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="<http://www.w3.org/2000/svg>" fill="none" viewBox="0 0 24 24">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
            </svg>
            Mengupload...
        `;
    });

    // Countdown timer
    const deadline = new Date('{{ $booking->created_at->addHours(24)->toISOString() }}');
    const countdownElement = document.getElementById('countdown');

    function updateCountdown() {
        const now = new Date().getTime();
        const distance = deadline.getTime() - now;

        if (distance < 0) {
            countdownElement.innerHTML = "EXPIRED";
            countdownElement.classList.add('text-red-600');
            return;
        }

        const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
        const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
        const seconds = Math.floor((distance % (1000 * 60)) / 1000);

        countdownElement.innerHTML = `${hours}j ${minutes}m ${seconds}d`;
    }

    setInterval(updateCountdown, 1000);
    updateCountdown();
});

function removeFile() {
    document.getElementById('payment_proof').value = '';
    document.getElementById('file-preview').classList.add('hidden');
    document.getElementById('drop-zone').style.display = 'flex';
}

function formatFileSize(bytes) {
    if (bytes === 0) return '0 Bytes';
    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

function copyToClipboard(text) {
    navigator.clipboard.writeText(text).then(function() {
        showToast('Nomor rekening berhasil disalin!', 'success');
    });
}
</script>
@endpush

Mengintegrasikan Frontend dengan Controller Laravel

Terakhir, kita perlu update routes dan ensure semua integration berjalan dengan baik. Update routes/web.php:

<?php

use App\\Http\\Controllers\\Web\\HomeController;
use App\\Http\\Controllers\\Web\\OfficeController;
use App\\Http\\Controllers\\Web\\BookingController;
use Illuminate\\Support\\Facades\\Route;

// Public routes
Route::get('/', [HomeController::class, 'index'])->name('home');
Route::post('/search', [HomeController::class, 'search'])->name('offices.search');

// Office routes
Route::prefix('offices')->group(function () {
    Route::get('/', [OfficeController::class, 'index'])->name('offices.index');
    Route::get('/{slug}', [OfficeController::class, 'show'])->name('offices.show');
    Route::get('/{office}/check-availability', [OfficeController::class, 'checkAvailability'])->name('offices.check-availability');
});

// Auth routes
Auth::routes();

// Protected routes
Route::middleware(['auth'])->group(function () {
    Route::get('/dashboard', [App\\Http\\Controllers\\DashboardController::class, 'index'])->name('dashboard');

    // Booking routes
    Route::prefix('bookings')->group(function () {
        Route::get('/create', [BookingController::class, 'create'])->name('bookings.create');
        Route::post('/store', [BookingController::class, 'store'])->name('bookings.store');
        Route::get('/{bookingCode}/payment', [BookingController::class, 'payment'])->name('bookings.payment');
        Route::post('/{bookingCode}/upload-payment', [BookingController::class, 'uploadPayment'])->name('bookings.upload-payment');
        Route::get('/history', [BookingController::class, 'history'])->name('bookings.history');
    });

    // Profile routes
    Route::get('/profile', [App\\Http\\Controllers\\ProfileController::class, 'edit'])->name('profile.edit');
    Route::patch('/profile', [App\\Http\\Controllers\\ProfileController::class, 'update'])->name('profile.update');
});

Bikin juga footer component di resources/views/layouts/footer.blade.php:

<footer class="bg-bwa-gray-900 text-white">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
        <div class="grid grid-cols-1 md:grid-cols-4 gap-8">
            <!-- Company Info -->
            <div class="md:col-span-2">
                <div class="flex items-center space-x-2 mb-4">
                    <div class="w-8 h-8 bg-gradient-bwa rounded-lg flex items-center justify-center">
                        <span class="text-white font-bold text-sm">BWA</span>
                    </div>
                    <span class="font-display font-semibold text-xl">Office Rental</span>
                </div>
                <p class="text-bwa-gray-300 mb-4 max-w-md">
                    Platform terpercaya untuk menyewa ruang kantor modern dengan lokasi strategis,
                    fasilitas lengkap, dan proses booking yang mudah.
                </p>
                <div class="flex space-x-4">
                    <a href="#" class="text-bwa-gray-400 hover:text-white">
                        <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"/></svg>
                    </a>
                    <a href="#" class="text-bwa-gray-400 hover:text-white">
                        <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z"/></svg>
                    </a>
                    <a href="#" class="text-bwa-gray-400 hover:text-white">
                        <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.174-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.096.118.112.221.085.343-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.402.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.357-.629-2.750-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24.009 12.017 24.009c6.624 0 11.99-5.367 11.99-11.988C24.007 5.367 18.641.001.012.001z"/></svg>
                    </a>
                </div>
            </div>

            <!-- Quick Links -->
            <div>
                <h3 class="font-semibold mb-4">Quick Links</h3>
                <ul class="space-y-2 text-bwa-gray-300">
                    <li><a href="{{ route('home') }}" class="hover:text-white">Beranda</a></li>
                    <li><a href="{{ route('offices.index') }}" class="hover:text-white">Cari Kantor</a></li>
                    <li><a href="#" class="hover:text-white">Tentang Kami</a></li>
                    <li><a href="#" class="hover:text-white">Kontak</a></li>
                    <li><a href="#" class="hover:text-white">FAQ</a></li>
                </ul>
            </div>

            <!-- Support -->
            <div>
                <h3 class="font-semibold mb-4">Support</h3>
                <ul class="space-y-2 text-bwa-gray-300">
                    <li>
                        <a href="tel:+628123456789" class="hover:text-white flex items-center">
                            <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path>
                            </svg>
                            +62 812-3456-789
                        </a>
                    </li>
                    <li>
                        <a href="mailto:[email protected]" class="hover:text-white flex items-center">
                            <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
                            </svg>
                            [email protected]
                        </a>
                    </li>
                    <li>
                        <span class="flex items-center">
                            <svg class="w-4 h-4 mr-2" 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>
                            24/7 Support
                        </span>
                    </li>
                </ul>
            </div>
        </div>

        <div class="border-t border-bwa-gray-800 mt-8 pt-8 flex flex-col md:flex-row justify-between items-center">
            <p class="text-bwa-gray-400 text-sm">
                &copy; {{ date('Y') }} BWA Office Rental. All rights reserved.
            </p>
            <div class="flex space-x-6 mt-4 md:mt-0">
                <a href="#" class="text-bwa-gray-400 hover:text-white text-sm">Privacy Policy</a>
                <a href="#" class="text-bwa-gray-400 hover:text-white text-sm">Terms of Service</a>
                <a href="#" class="text-bwa-gray-400 hover:text-white text-sm">Cookie Policy</a>
            </div>
        </div>
    </div>
</footer>

Perfect! Sekarang kita udah punya frontend yang comprehensive dengan:

  1. Homepage yang engaging dengan hero section, stats, featured offices, dan search functionality
  2. Detail office page yang informatif dengan galeri foto, booking form, dan availability checker
  3. Checkout page yang user-friendly dengan form booking yang comprehensive
  4. Payment page dengan multiple bank options dan secure file upload
  5. Responsive design yang works perfect di semua devices
  6. Interactive elements dengan JavaScript yang smooth
  7. Integration yang seamless dengan backend Laravel

Semua udah terintegrasi dengan controller Laravel dan database yang udah kita setup sebelumnya. User experience-nya bakal smooth dari browse kantor sampai upload bukti pembayaran!

Integrasi Midtrans Payment Gateway: Pembayaran Online yang Modern

Sekarang kita bakal integrasikan Midtrans payment gateway ke sistem BWA Office Rental kita. Midtrans adalah payment gateway terpopuler di Indonesia yang support berbagai metode pembayaran kayak kartu kredit, e-wallet, virtual account, dan masih banyak lagi.

Dengan integrasi ini, customer ga perlu lagi ribet upload bukti transfer manual - semua bisa otomatis dan real-time!

Setup Midtrans dan Instalasi Package

Pertama, kita perlu install Midtrans PHP SDK:

composer require midtrans/midtrans-php

Bikin akun di midtrans.com dan dapetin Server Key & Client Key. Tambahin ke file .env:

MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxxxx
MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxxxx
MIDTRANS_IS_PRODUCTION=false
MIDTRANS_IS_SANITIZED=true
MIDTRANS_IS_3DS=true

Bikin config file buat Midtrans 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),
];

Update Model Payment untuk Support Midtrans

Edit model Payment di app/Models/Payment.php:

// Tambahkan di fillable array
protected $fillable = [
    'booking_id',
    'payment_method',
    'amount',
    'payment_proof',
    'payment_date',
    'verified_at',
    'verified_by',
    'status',
    'reference_number',
    'notes',
    'midtrans_order_id',      // tambahan
    'midtrans_transaction_id', // tambahan
    'midtrans_payment_type',   // tambahan
    'midtrans_response',       // tambahan
];

// Tambahkan cast
protected $casts = [
    'amount' => 'decimal:2',
    'payment_date' => 'datetime',
    'verified_at' => 'datetime',
    'midtrans_response' => 'array', // tambahan
];

Bikin migration buat nambah kolom Midtrans:

php artisan make:migration add_midtrans_fields_to_payments_table

<?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('payments', function (Blueprint $table) {
            $table->string('midtrans_order_id')->nullable()->after('reference_number');
            $table->string('midtrans_transaction_id')->nullable()->after('midtrans_order_id');
            $table->string('midtrans_payment_type')->nullable()->after('midtrans_transaction_id');
            $table->json('midtrans_response')->nullable()->after('midtrans_payment_type');
        });
    }

    public function down(): void
    {
        Schema::table('payments', function (Blueprint $table) {
            $table->dropColumn(['midtrans_order_id', 'midtrans_transaction_id', 'midtrans_payment_type', 'midtrans_response']);
        });
    }
};

Jalankan migration:

php artisan migrate

Bikin Service Class untuk Midtrans

Bikin service class di app/Services/MidtransService.php:

<?php

namespace App\\Services;

use Midtrans\\Config;
use Midtrans\\Snap;
use Midtrans\\Transaction;
use App\\Models\\Booking;
use App\\Models\\Payment;
use Illuminate\\Support\\Facades\\Log;

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 createTransaction(Booking $booking)
    {
        $orderId = 'BWA-' . $booking->booking_code . '-' . time();

        $params = [
            'transaction_details' => [
                'order_id' => $orderId,
                'gross_amount' => (int) $booking->total_amount,
            ],
            'customer_details' => [
                'first_name' => $booking->user->name,
                'email' => $booking->user->email,
                'phone' => $booking->user->phone,
            ],
            'item_details' => [
                [
                    'id' => $booking->office->id,
                    'price' => (int) $booking->total_amount,
                    'quantity' => 1,
                    'name' => 'BWA Office Rental - ' . $booking->office->name,
                    'category' => 'Office Rental',
                ]
            ],
            'callbacks' => [
                'finish' => route('payment.finish'),
                'unfinish' => route('payment.unfinish'),
                'error' => route('payment.error'),
            ],
            'enabled_payments' => [
                'credit_card', 'gopay', 'shopeepay', 'other_qris',
                'bca_va', 'bni_va', 'bri_va', 'permata_va',
                'other_va', 'alfamart', 'indomaret'
            ],
            'custom_field1' => $booking->booking_code,
            'custom_field2' => 'BWA-Office-Rental',
        ];

        try {
            $snapToken = Snap::getSnapToken($params);

            // Update payment dengan Midtrans order ID
            $booking->payment->update([
                'midtrans_order_id' => $orderId,
                'payment_method' => 'midtrans',
            ]);

            return $snapToken;

        } catch (\\Exception $e) {
            Log::error('Midtrans error: ' . $e->getMessage());
            throw new \\Exception('Gagal membuat transaksi pembayaran');
        }
    }

    public function handleCallback($notification)
    {
        try {
            $orderId = $notification->order_id;
            $transactionStatus = $notification->transaction_status;
            $fraudStatus = $notification->fraud_status ?? '';

            // Cari payment berdasarkan order ID
            $payment = Payment::where('midtrans_order_id', $orderId)->first();

            if (!$payment) {
                Log::warning('Payment not found for order ID: ' . $orderId);
                return false;
            }

            // Update payment dengan response dari Midtrans
            $payment->update([
                'midtrans_transaction_id' => $notification->transaction_id,
                'midtrans_payment_type' => $notification->payment_type,
                'midtrans_response' => json_decode(json_encode($notification), true),
            ]);

            // Handle status
            if ($transactionStatus == 'capture') {
                if ($fraudStatus == 'challenge') {
                    $payment->update(['status' => 'pending']);
                } else if ($fraudStatus == 'accept') {
                    $this->confirmPayment($payment);
                }
            } else if ($transactionStatus == 'settlement') {
                $this->confirmPayment($payment);
            } else if ($transactionStatus == 'pending') {
                $payment->update(['status' => 'pending']);
            } else if (in_array($transactionStatus, ['deny', 'expire', 'cancel'])) {
                $payment->update(['status' => 'failed']);
            }

            return true;

        } catch (\\Exception $e) {
            Log::error('Midtrans callback error: ' . $e->getMessage());
            return false;
        }
    }

    private function confirmPayment(Payment $payment)
    {
        $payment->update([
            'status' => 'verified',
            'payment_date' => now(),
            'verified_at' => now(),
        ]);

        // Update booking status
        $payment->booking->update(['status' => 'confirmed']);

        // Send confirmation email atau notification
        // implementasi sesuai kebutuhan
    }
}

Update Controller untuk Midtrans Integration

Update BookingController di app/Http/Controllers/Web/BookingController.php:

// Tambahkan use statement
use App\\Services\\MidtransService;

// Update method payment
public function payment($bookingCode)
{
    $booking = Booking::with(['office', 'payment'])
        ->where('booking_code', $bookingCode)
        ->where('user_id', Auth::id())
        ->firstOrFail();

    if ($booking->status !== 'pending') {
        return redirect()->route('dashboard')->with('info', 'Booking ini sudah diproses.');
    }

    // Create Midtrans token
    try {
        $midtransService = new MidtransService();
        $snapToken = $midtransService->createTransaction($booking);

        return view('bookings.payment', compact('booking', 'snapToken'));

    } catch (\\Exception $e) {
        return redirect()->back()->with('error', 'Gagal memuat halaman pembayaran: ' . $e->getMessage());
    }
}

// Tambah method untuk handle callback
public function midtransCallback(Request $request)
{
    $midtransService = new MidtransService();
    $success = $midtransService->handleCallback($request);

    if ($success) {
        return response('OK', 200);
    } else {
        return response('FAILED', 400);
    }
}

// Tambah method untuk handle redirect
public function paymentFinish(Request $request)
{
    $orderId = $request->order_id;
    $payment = Payment::where('midtrans_order_id', $orderId)->first();

    if ($payment) {
        return redirect()->route('dashboard')->with('success', 'Pembayaran berhasil! Booking akan segera dikonfirmasi.');
    }

    return redirect()->route('dashboard')->with('error', 'Terjadi kesalahan dalam pembayaran.');
}

public function paymentUnfinish(Request $request)
{
    return redirect()->route('dashboard')->with('warning', 'Pembayaran belum selesai. Silakan lanjutkan pembayaran.');
}

public function paymentError(Request $request)
{
    return redirect()->route('dashboard')->with('error', 'Terjadi kesalahan dalam proses pembayaran.');
}

Update View Payment dengan Midtrans

Update view resources/views/bookings/payment.blade.php, tambahkan Midtrans payment option:

@extends('layouts.app')

@section('title', 'Pembayaran - BWA Office Rental')

@push('styles')
<script src="<https://app.sandbox.midtrans.com/snap/snap.js>" data-client-key="{{ config('midtrans.client_key') }}"></script>
@endpush

@section('content')
<!-- existing content... -->

<!-- Payment Methods -->
<div class="card p-6">
    <h2 class="text-xl font-semibold text-bwa-gray-900 mb-4">Pilih Metode Pembayaran</h2>

    <!-- Midtrans Payment -->
    <div class="border-2 border-bwa-primary-200 rounded-lg p-6 mb-6 bg-bwa-primary-50">
        <div class="flex items-center justify-between mb-4">
            <div>
                <h3 class="font-semibold text-lg text-bwa-primary-900">Pembayaran Online</h3>
                <p class="text-sm text-bwa-primary-700">Kartu kredit, e-wallet, virtual account, dan lainnya</p>
            </div>
            <div class="flex space-x-2">
                <img src="<https://logo.midtrans.com/assets/img/method/visa.png>" alt="Visa" class="h-6">
                <img src="<https://logo.midtrans.com/assets/img/method/mastercard.png>" alt="Mastercard" class="h-6">
                <img src="<https://logo.midtrans.com/assets/img/method/gopay.png>" alt="GoPay" class="h-6">
                <img src="<https://logo.midtrans.com/assets/img/method/shopeepay.png>" alt="ShopeePay" class="h-6">
            </div>
        </div>

        <button onclick="payWithMidtrans()" class="btn-primary w-full">
            <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 - {{ $booking->formatted_total_amount }}
        </button>
    </div>

    <!-- Manual Transfer (existing) -->
    <div class="border border-bwa-gray-200 rounded-lg p-6">
        <div class="flex items-center justify-between mb-4">
            <div>
                <h3 class="font-semibold text-lg text-bwa-gray-900">Transfer Manual</h3>
                <p class="text-sm text-bwa-gray-600">Transfer ke rekening bank dan upload bukti</p>
            </div>
        </div>

        <button onclick="toggleManualTransfer()" class="btn-secondary w-full" id="manual-transfer-btn">
            Lihat Detail Transfer Manual
        </button>

        <div id="manual-transfer-section" class="hidden mt-4">
            <!-- existing manual transfer content -->
        </div>
    </div>
</div>

@endsection

@push('scripts')
<script>
const snapToken = '{{ $snapToken }}';

function payWithMidtrans() {
    snap.pay(snapToken, {
        onSuccess: function(result) {
            console.log('Payment success:', result);
            window.location.href = '{{ route("payment.finish") }}?order_id=' + result.order_id;
        },
        onPending: function(result) {
            console.log('Payment pending:', result);
            showToast('Pembayaran pending, silakan selesaikan pembayaran Anda', 'warning');
        },
        onError: function(result) {
            console.log('Payment error:', result);
            showToast('Terjadi kesalahan dalam pembayaran', 'error');
        },
        onClose: function() {
            console.log('Payment popup closed');
            showToast('Pembayaran dibatalkan', 'info');
        }
    });
}

function toggleManualTransfer() {
    const section = document.getElementById('manual-transfer-section');
    const btn = document.getElementById('manual-transfer-btn');

    if (section.classList.contains('hidden')) {
        section.classList.remove('hidden');
        btn.textContent = 'Sembunyikan Transfer Manual';
    } else {
        section.classList.add('hidden');
        btn.textContent = 'Lihat Detail Transfer Manual';
    }
}
</script>
@endpush

Update Routes untuk Midtrans

Tambahkan routes di routes/web.php:

// Midtrans callback routes
Route::post('/payment/callback', [BookingController::class, 'midtransCallback'])->name('payment.callback');
Route::get('/payment/finish', [BookingController::class, 'paymentFinish'])->name('payment.finish');
Route::get('/payment/unfinish', [BookingController::class, 'paymentUnfinish'])->name('payment.unfinish');
Route::get('/payment/error', [BookingController::class, 'paymentError'])->name('payment.error');

Testing dan Go Live

Untuk testing, pastiin pake Server Key dan Client Key yang sandbox. Kalo udah ready production, ganti ke production keys dan ubah MIDTRANS_IS_PRODUCTION=true.

Test Cards untuk Sandbox:

  • Success: 4811 1111 1111 1114
  • Failure: 4911 1111 1111 1113
  • Challenge: 4411 1111 1111 1118

Webhook Configuration: Di dashboard Midtrans, set webhook URL ke:

<https://yourdomain.com/payment/callback>

Monitoring dan Error Handling

Buat command untuk check payment status:

php artisan make:command CheckPendingPayments

<?php

namespace App\\Console\\Commands;

use Illuminate\\Console\\Command;
use App\\Models\\Payment;
use App\\Services\\MidtransService;

class CheckPendingPayments extends Command
{
    protected $signature = 'payment:check-pending';
    protected $description = 'Check pending payments status with Midtrans';

    public function handle()
    {
        $pendingPayments = Payment::where('status', 'pending')
            ->where('payment_method', 'midtrans')
            ->where('created_at', '>', now()->subHours(24))
            ->get();

        $midtransService = new MidtransService();

        foreach ($pendingPayments as $payment) {
            try {
                $status = \\Midtrans\\Transaction::status($payment->midtrans_order_id);
                $midtransService->handleCallback($status);

                $this->info("Updated payment: {$payment->midtrans_order_id}");

            } catch (\\Exception $e) {
                $this->error("Failed to check payment {$payment->midtrans_order_id}: " . $e->getMessage());
            }
        }
    }
}

Jadwalkan command ini di app/Console/Kernel.php:

protected function schedule(Schedule $schedule)
{
    $schedule->command('payment:check-pending')->everyFiveMinutes();
}

Perfect! Sekarang BWA Office Rental udah punya payment gateway yang modern dan comprehensive. Customer bisa bayar dengan berbagai metode, otomatis ter-update, dan admin ga perlu manual verification lagi!

Tips Penting:

  • Selalu test di sandbox environment dulu
  • Monitor webhook logs buat debugging
  • Set proper timeout buat payment (biasanya 24 jam)
  • Implementasikan proper error handling dan logging
  • Backup manual transfer sebagai fallback option

Dengan integrasi Midtrans ini, conversion rate booking pasti bakal meningkat karena proses pembayaran jadi lebih mudah dan trustworthy!

Summary dan Penutup: Journey Menuju Full-Stack Developer

Selamat! Kamu udah berhasil menyelesaikan tutorial lengkap pembuatan website sewa kantor menggunakan Laravel 12, Filament 3, dan Spatie Role. Ini bukan pencapaian yang kecil lho - kamu udah berhasil bikin aplikasi web yang complex dan production-ready!

Apa yang Udah Kita Capai?

Mari kita recap perjalanan coding kita dari awal sampai akhir:

🏗️ Foundation yang Solid

  • Setup Laravel 12 dengan konfigurasi yang optimal
  • Database design yang scalable dengan 7 tabel utama
  • Model relationships yang efficient menggunakan Eloquent ORM
  • Authentication dan authorization dengan Spatie Permission

🎨 Admin Panel yang Powerfull

  • Dashboard Filament 3 yang modern dan responsive
  • CRUD operations yang comprehensive untuk semua entity
  • Role-based access control yang secure
  • Custom actions dan bulk operations

💻 Frontend yang Memukau

  • Homepage yang engaging dengan search functionality
  • Detail office page dengan gallery dan booking system
  • Checkout flow yang smooth dan user-friendly
  • Responsive design menggunakan Tailwind CSS

💳 Payment Integration

  • Manual bank transfer dengan file upload
  • Midtrans integration untuk payment gateway modern
  • Webhook handling untuk auto-confirmation
  • Multiple payment methods support

Skills yang Udah Kamu Kuasai

Setelah menyelesaikan tutorial ini, kamu udah menguasai:

Backend Development:

  • Laravel framework dari basic sampai advanced
  • Database migration dan model relationships
  • API development dan form handling
  • File upload dan validation
  • Authentication dan authorization

Frontend Development:

  • Blade templating dengan component-based approach
  • Tailwind CSS untuk modern styling
  • JavaScript untuk interactivity
  • Responsive web design
  • User experience optimization

Full-Stack Integration:

  • MVC architecture pattern
  • RESTful API concepts
  • Payment gateway integration
  • Real-time notifications
  • Error handling dan debugging

Development Tools:

  • Composer untuk dependency management
  • Artisan commands untuk productivity
  • Git workflow (hopefully!)
  • Environment configuration

Tips Buat Pengembangan Selanjutnya

🚀 Level Up Your App:

  • Tambahkan real-time chat antara customer dan admin
  • Implementasikan notification system (email/SMS/WhatsApp)
  • Bikin mobile app dengan Laravel API
  • Add advanced search dengan Elasticsearch
  • Implementasikan caching untuk performance

🔒 Security Enhancements:

  • Two-factor authentication untuk admin
  • Rate limiting untuk API endpoints
  • CSRF protection yang proper
  • Input sanitization dan validation
  • Regular security audits

📊 Analytics & Monitoring:

  • Google Analytics integration
  • Performance monitoring dengan tools kayak New Relic
  • Error tracking dengan Sentry
  • User behavior analytics
  • Revenue tracking dan reporting

Saran Belajar Lanjutan di BuildWithAngga

Kalo kamu udah nyaman dengan foundation yang udah kita bangun, saatnya eksplorasi lebih dalam! Di BuildWithAngga ada banyak kelas yang bisa nge-boost skill kamu:

🎯 Recommended Learning Path:

1. Advanced Laravel Development

  • Mastery Laravel API untuk mobile integration
  • Laravel Queue dan Background Jobs
  • Laravel Testing (Unit & Feature Tests)
  • Laravel Performance Optimization

2. Modern Frontend Development

  • Vue.js atau React untuk SPA development
  • Inertia.js untuk seamless Laravel-Vue/React integration
  • Progressive Web App (PWA) development
  • Mobile app development dengan Flutter

3. DevOps & Production

  • Docker untuk containerization
  • AWS/Google Cloud deployment
  • CI/CD pipeline setup
  • Database optimization dan scaling

4. Specialized Skills

  • Payment gateway mastery (Stripe, PayPal, dll)
  • Real-time applications dengan WebSocket
  • Microservices architecture
  • GraphQL API development

Kenapa Harus Lanjut Belajar di BWA?

👨‍🏫 Mentor Berpengalaman

  • Instructor yang udah proven di industri
  • Real-world experience, bukan cuma teori
  • Responsive di community Discord
  • Career guidance dan mentoring

🎓 Learning Method yang Effective

  • Project-based learning kayak tutorial ini
  • Step-by-step yang beginner friendly
  • Video berkualitas tinggi dengan audio jernih
  • Source code lengkap dan documented

🤝 Community yang Supportive

  • Discord community yang aktif 24/7
  • Sharing knowledge antar member
  • Code review dan feedback
  • Networking dengan fellow developers

💼 Career Support

  • Job placement assistance
  • Portfolio review dan feedback
  • Interview preparation
  • Industry connections

Next Steps: Transform Your Learning

Jangka Pendek (1-3 bulan):

  • Polish aplikasi ini dengan fitur tambahan
  • Deploy ke production server (shared hosting/VPS)
  • Buat portfolio website dan showcase project ini
  • Join BWA community di Discord

Jangka Menengah (3-6 bulan):

  • Ambil kelas advanced di BWA sesuai minat
  • Kontribusi ke open source projects
  • Freelance dengan skill yang udah dipunya
  • Networking dengan developer lain

Jangka Panjang (6+ bulan):

  • Apply full-time developer position
  • Build startup atau produk sendiri
  • Jadi mentor buat junior developer
  • Specialized di niche tertentu (fintech, e-commerce, dll)

Final Words: Your Developer Journey Starts Here

Tutorial ini adalah starting point, bukan finish line. Skill coding itu kayak otot - harus terus dilatih supaya makin kuat. Jangan takut buat eksperimen, break things, dan belajar dari error.

Remember: setiap expert pernah jadi beginner. Yang beda adalah consistency dan willingness to learn. Kamu udah prove bahwa kamu bisa menyelesaikan project complex kayak ini - that's a huge achievement!

🔥 Keep The Momentum Going:

  • Code every day, walau cuma 30 menit
  • Join coding communities dan aktif diskusi
  • Build more projects untuk strengthen portfolio
  • Share knowledge dengan yang lain
  • Never stop learning new technologies

💪 You Got This!

Di BWA, kita percaya bahwa setiap orang bisa jadi developer yang excellent dengan guidance yang tepat dan usaha yang konsisten. Kamu udah buktiin bahwa kamu punya dedication buat belajar hal baru.

Sekarang saatnya apply knowledge ini ke dunia nyata. Build more projects, collaborate dengan orang lain, dan most importantly - enjoy the process!


Ready untuk step selanjutnya?

Kunjungi BuildWithAngga.com dan explore kelas-kelas yang bisa elevate skill kamu ke level berikutnya. Di sana kamu bakal ketemu dengan community developer Indonesia yang passionate dan supportive.

Happy Coding! 🚀

"Code is like humor. When you have to explain it, it's bad." - Cory House

Tapi tutorial ini exception ya - explaining is part of learning! 😄