Initial Commit

This commit is contained in:
ningfaiqohalfaqorina 2026-07-15 23:26:09 +07:00
commit 6db3c09f78
310 changed files with 45605 additions and 0 deletions

18
.editorconfig Normal file
View File

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4

65
.env.example Normal file
View File

@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View File

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db

1
.npmrc Normal file
View File

@ -0,0 +1 @@
ignore-scripts=true

143
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,143 @@
# Deployment Guide
Panduan deployment Laravel untuk project WebRental.
## Kebutuhan Server
- PHP 8.2 atau lebih baru
- Composer
- Web server: Nginx atau Apache
- MySQL / MariaDB
- Extension PHP umum:
- `ctype`
- `curl`
- `dom`
- `fileinfo`
- `filter`
- `mbstring`
- `openssl`
- `pdo_mysql`
- `tokenizer`
- `xml`
- `zip`
## Langkah Deployment
1. Upload source code ke server.
2. Install dependency production.
```bash
composer install --optimize-autoloader --no-dev
```
3. Salin file environment.
```bash
cp .env.example .env
```
4. Edit `.env` produksi.
```env
APP_NAME=WebRental
APP_ENV=production
APP_DEBUG=false
APP_URL=https://domain-anda.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=webrental
DB_USERNAME=webrental_user
DB_PASSWORD=rahasia
```
5. Generate application key jika belum ada.
```bash
php artisan key:generate
```
6. Jalankan migration.
```bash
php artisan migrate --force
```
7. Jika butuh data demo untuk presentasi, jalankan seeder.
```bash
php artisan db:seed --force
```
8. Buat storage symlink.
```bash
php artisan storage:link
```
9. Optimalkan cache Laravel.
```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
```
## Permission Folder
Pastikan folder ini writable oleh web server:
- `storage/`
- `bootstrap/cache/`
Contoh di Linux:
```bash
chmod -R 775 storage bootstrap/cache
```
## Setelah Deploy
- Pastikan `.env` tidak bisa diakses publik
- Pastikan `public/index.php` menjadi document root
- Pastikan `public/storage` tersedia
- Pastikan database backup dibuat sebelum deploy
## Catatan Keamanan
- `APP_DEBUG=false` di production
- Gunakan password database yang kuat
- Jangan upload `.env` ke repository publik
- Gunakan HTTPS
- Backup database secara rutin
- Jika seeder demo dipakai di staging, hindari di production final kecuali memang diperlukan
## Command Operasional
```bash
php artisan migrate --force
php artisan db:seed --force
php artisan storage:link
php artisan optimize
```
## Troubleshooting
- Jika view lama masih muncul, jalankan:
```bash
php artisan optimize:clear
```
- Jika route tidak terbaca, jalankan:
```bash
php artisan route:clear
```
- Jika cache konfigurasi bermasalah, jalankan:
```bash
php artisan config:clear
php artisan view:clear
```

View File

@ -0,0 +1,262 @@
# Dokumentasi CRUD Data Kendaraan Admin Rental
Dokumentasi ini menjelaskan tahap 2 backend yang sudah dibuat untuk fitur **Data Kendaraan** milik admin rental.
## Tujuan Fitur
Fitur ini dibuat agar admin rental dapat mengelola kendaraan milik rental company-nya sendiri dengan aman dan terstruktur.
## Ruang Lingkup
Fitur yang sudah dibuat mencakup:
- list kendaraan
- tambah kendaraan
- simpan kendaraan
- edit kendaraan
- update kendaraan
- hapus kendaraan
- upload foto utama kendaraan
- upload banyak foto galeri kendaraan
- hapus foto galeri satu per satu
Fitur ini khusus untuk role:
- `admin_rental`
## Aturan Akses
- Hanya user dengan role `admin_rental` yang bisa mengakses CRUD kendaraan.
- Admin rental hanya bisa melihat kendaraan milik rental company miliknya sendiri.
- Admin rental hanya bisa membuat, mengubah, dan menghapus kendaraan milik rental company miliknya sendiri.
- Jika admin belum punya rental company, sistem menampilkan pesan yang jelas.
- Jika kendaraan bukan milik rental admin login, sistem mengembalikan `404`.
## Route yang Dibuat
Route kendaraan admin rental sudah ditambahkan di bawah prefix `/admin-rental`.
- `GET /admin-rental/vehicles` → list kendaraan
- `GET /admin-rental/vehicles/create` → form tambah kendaraan
- `POST /admin-rental/vehicles` → simpan kendaraan baru
- `GET /admin-rental/vehicles/{vehicle}/edit` → form edit kendaraan
- `PUT /admin-rental/vehicles/{vehicle}` → update kendaraan
- `DELETE /admin-rental/vehicles/{vehicle}` → hapus kendaraan
- `DELETE /admin-rental/vehicles/gallery/{image}` → hapus gambar galeri
Route name yang dipakai:
- `admin-rental.vehicles.index`
- `admin-rental.vehicles.create`
- `admin-rental.vehicles.store`
- `admin-rental.vehicles.edit`
- `admin-rental.vehicles.update`
- `admin-rental.vehicles.destroy`
- `admin-rental.vehicles.gallery.destroy`
## File Backend yang Ditambahkan
### Controller
- `app/Http/Controllers/AdminRental/VehicleController.php`
### Form Request
- `app/Http/Requests/StoreVehicleRequest.php`
- `app/Http/Requests/UpdateVehicleRequest.php`
### Blade Views
- `resources/views/admin-rental/vehicles/index.blade.php`
- `resources/views/admin-rental/vehicles/create.blade.php`
- `resources/views/admin-rental/vehicles/edit.blade.php`
- `resources/views/admin-rental/vehicles/form.blade.php`
### Route Update
- `routes/web.php`
### Sidebar Update
- `resources/views/components/admin-rental-sidebar.blade.php`
## Model yang Dipakai
Fitur ini menggunakan model yang sudah ada:
- `App\Models\Vehicle`
- `App\Models\VehicleImage`
- `App\Models\RentalCompany`
- `App\Models\User`
## Alur CRUD Kendaraan
### 1. List Kendaraan
- Admin rental membuka halaman Data Kendaraan.
- Sistem mengambil data kendaraan berdasarkan `rental_company_id` milik user login.
- Data ditampilkan dengan pagination.
- Bisa melakukan pencarian sederhana berdasarkan nama, brand, atau kategori.
### 2. Tambah Kendaraan
- Admin klik tombol tambah kendaraan.
- Form tampil dengan input field kendaraan.
- `rental_company_id` tidak dipilih manual, tetapi diambil otomatis dari user login.
- Slug dibuat otomatis dari nama kendaraan.
- Jika upload foto utama dilakukan, file disimpan ke `storage/public`.
- Jika upload galeri dilakukan, file disimpan ke `storage/public` dan record masuk ke tabel `vehicle_images`.
### 3. Edit Kendaraan
- Admin membuka form edit kendaraan.
- Sistem memastikan kendaraan tersebut milik rental company milik admin login.
- Data lama ditampilkan kembali di form.
- Admin bisa mengganti data utama.
- Admin bisa mengganti foto utama.
- Admin bisa menambah gambar galeri baru.
- Admin bisa menghapus gambar galeri lama.
### 4. Update Kendaraan
- Validasi dilakukan ulang.
- Slug diperbarui dari nama baru dan dijaga tetap unik.
- Jika foto utama diganti, file lama dihapus dari storage bila ada.
- Jika gambar galeri dihapus, file dan record juga dihapus.
### 5. Hapus Kendaraan
- Sistem memastikan kendaraan yang dihapus masih berada dalam lingkup rental company milik admin login.
- File main image dihapus jika ada.
- Semua gallery image juga dihapus dari storage dan database.
- Record kendaraan dihapus.
### 6. Hapus Gambar Galeri
- Admin bisa menghapus satu gambar galeri saja dari halaman edit.
- Sistem akan menghapus file gambar dan record database-nya.
## Validasi Input
### Store Vehicle
Validasi yang diterapkan:
- `name` wajib
- `brand` wajib
- `type` wajib
- `category` wajib
- `year` wajib, numerik, dan masuk akal
- `transmission` wajib
- `fuel_type` wajib
- `seat_capacity` wajib, numerik
- `luggage_capacity` nullable, numerik
- `color` nullable
- `price_per_day` wajib, numerik, minimal 0
- `description` nullable
- `terms_conditions` nullable
- `status` wajib, hanya `active`, `inactive`, atau `maintenance`
- `main_image` nullable, harus gambar
- `gallery_images.*` nullable, harus gambar
### Update Vehicle
Validasi update sama seperti create, ditambah:
- `delete_gallery_images[]` untuk memilih gambar galeri yang akan dihapus
## Keamanan Data
Pengamanan yang diterapkan:
- Akses route diproteksi dengan middleware `auth` + `role:admin_rental`.
- Query kendaraan dibatasi berdasarkan `rental_company_id` milik user login.
- Akses ke kendaraan milik rental lain ditolak dengan `404`.
- Upload gambar disimpan ke `storage/public`.
- Hapus kendaraan juga membersihkan file gambar terkait.
## Storage Gambar
Karena file gambar disimpan di `storage/public`, jalankan perintah berikut jika belum dilakukan:
```bash
php artisan storage:link
```
## Flash Message yang Dipakai
Fitur ini sudah menampilkan flash message sederhana untuk:
- berhasil tambah kendaraan
- berhasil update kendaraan
- berhasil hapus kendaraan
- gagal akses karena rental company belum ada
## Struktur Data Kendaraan
### Tabel `vehicles`
Field yang digunakan:
- `name`
- `slug`
- `brand`
- `type`
- `category`
- `year`
- `transmission`
- `fuel_type`
- `seat_capacity`
- `luggage_capacity`
- `color`
- `price_per_day`
- `description`
- `terms_conditions`
- `status`
- `main_image`
### Tabel `vehicle_images`
Field yang digunakan:
- `vehicle_id`
- `image_path`
- `is_primary`
## Sidebar Admin Rental
Menu sidebar admin rental sudah dihubungkan ke halaman Data Kendaraan:
- `Dashboard`
- `Data Kendaraan`
## Catatan Teknis
- Slug dibuat otomatis dari nama kendaraan.
- Slug update dibuat tetap unik agar aman dipakai di URL atau relasi data berikutnya.
- Main image dan gallery image dipisahkan agar lebih fleksibel untuk tahap katalog customer nanti.
- Sistem ini disiapkan supaya nanti gampang disambungkan ke fitur booking dan katalog customer.
## Langkah Menjalankan
1. Pastikan migration sudah dijalankan.
2. Pastikan role `admin_rental` sudah bisa login.
3. Jalankan storage link jika belum ada:
```bash
php artisan storage:link
```
4. Buka menu admin rental.
5. Masuk ke **Data Kendaraan**.
6. Tambah kendaraan dan upload gambar sesuai kebutuhan.
## Status Pengerjaan
- CRUD kendaraan admin rental sudah selesai.
- Akses data sudah dibatasi per rental company.
- Upload gambar utama dan galeri sudah siap.
- Fitur ini siap dipakai sebagai dasar untuk tahap katalog customer dan booking.
## Langkah Berikutnya
Tahap berikutnya yang paling masuk akal:
1. membuat detail kendaraan untuk admin rental
2. menyiapkan katalog customer berdasarkan kendaraan yang approved
3. membangun booking backend yang terhubung ke kendaraan ini

View File

@ -0,0 +1,308 @@
# Dokumentasi Backend Booking Customer
Dokumentasi ini menjelaskan tahap 4 backend yang sudah dibuat untuk **booking customer** pada website rental kendaraan berbasis Laravel.
## Tujuan Fitur
Tahap ini dibuat agar customer bisa melakukan booking kendaraan secara valid menggunakan data nyata dari database.
Fokus fitur ini:
- hanya customer login yang boleh booking
- kendaraan yang dibooking harus valid dan layak tampil
- customer mengisi data booking melalui form frontend yang sudah ada
- sistem memvalidasi data booking secara backend
- sistem mengecek bentrok tanggal booking kendaraan
- booking yang valid disimpan ke database
- payment awal dibuat otomatis dengan status unpaid
- booking diarahkan ke langkah pembayaran placeholder
## Aturan Bisnis yang Diterapkan
Aturan yang wajib dipatuhi:
- customer wajib login sebelum booking
- rental yang belum diverifikasi tidak boleh dibooking
- kendaraan dengan status selain `active` tidak boleh dibooking
- satu kendaraan tidak boleh dibooking pada tanggal yang sama jika booking aktif masih memblokir
- customer hanya boleh booking kendaraan dari rental dengan status `approved`
- backend pembayaran penuh belum dibuat di tahap ini
- review belum dibuat di tahap ini
Status booking yang dianggap memblokir kendaraan:
- `pending`
- `waiting_payment`
- `waiting_verification`
- `confirmed`
- `ongoing`
Status yang tidak memblokir:
- `cancelled`
- `completed`
## Route yang Dibuat
Route booking customer yang digunakan:
- `GET /booking/{vehicle:slug}``booking.create`
- `POST /booking/{vehicle:slug}``booking.store`
Catatan:
- route booking hanya tersedia untuk user dengan role `customer`
- guest diarahkan ke login
- admin rental dan super admin tidak masuk flow booking customer
- route booking lama yang umum tanpa slug tidak dipakai untuk create flow
## File Backend yang Ditambahkan
### Controller
- `app/Http/Controllers/Customer/BookingController.php`
### Form Request
- `app/Http/Requests/StoreBookingRequest.php`
### Model yang Disesuaikan
- `app/Models/Booking.php`
- `app/Models/Payment.php`
- `app/Models/Vehicle.php`
- `app/Models/RentalCompany.php`
### Route Update
- `routes/web.php`
## Alur Booking Customer
### 1. Tombol Booking dari Detail Mobil
- Tombol `Booking Sekarang` di detail mobil diarahkan ke route `booking.create`.
- Jika guest klik tombol ini, sistem mengarah ke halaman login.
- Jika user login bukan customer, sistem diarahkan ke home sebagai langkah aman.
### 2. Halaman Booking
- Customer membuka halaman booking berdasarkan slug kendaraan.
- Controller memuat data kendaraan beserta rental company dan foto kendaraan.
- Sistem mengecek bahwa:
- kendaraan berstatus `active`
- rental company berstatus `approved`
- Jika tidak valid, sistem mengembalikan `404`.
### 3. Form Data Penyewa
Field yang dipakai dari form booking:
- nama lengkap
- email
- nomor HP
- alamat
- nomor KTP
- nomor SIM
- catatan tambahan
Prefill yang dipakai:
- `customer_name` dari nama user login
- `customer_email` dari email user login
- `customer_phone` dari nomor HP user login jika ada
Jika tidak ada customer profile tambahan, data tetap diambil dari `users` dan input form yang tersedia.
### 4. Detail Pemesanan
Field booking yang diproses:
- pickup_date
- return_date
- pickup_time nullable
- pickup_location
- return_location nullable
- with_driver boolean
- note nullable
Logika yang diterapkan:
- `pickup_date` wajib
- `return_date` wajib
- `return_date` harus lebih besar atau sama dengan `pickup_date`
- `pickup_date` tidak boleh di masa lalu
- duration dihitung otomatis di backend
- subtotal dihitung otomatis dari duration dan harga kendaraan per hari
- discount_amount sementara `0`
- additional_cost sementara `0`
- total_amount dihitung ulang di backend
## Validasi Booking
Validasi utama yang dipakai di `StoreBookingRequest`:
- `customer_name` wajib
- `customer_email` wajib dan format email valid
- `customer_phone` wajib
- `customer_address` wajib
- `identity_number` wajib
- `driver_license_number` nullable
- `pickup_date` wajib dan minimal hari ini
- `return_date` wajib dan tidak boleh lebih kecil dari `pickup_date`
- `pickup_location` wajib
- `pickup_time` nullable dengan format jam yang valid
- `with_driver` boolean
- `note` nullable
Catatan:
- nilai harga, subtotal, total, dan durasi tidak diambil dari frontend
- semua perhitungan harga dihitung ulang di backend
## Logika Bentrok Tanggal
Sebelum booking disimpan, sistem mengecek apakah kendaraan sudah dibooking pada rentang tanggal yang sama.
Logika overlap yang dipakai:
- cek booking lain dengan `vehicle_id` yang sama
- cek booking yang statusnya masih memblokir kendaraan
- overlap dianggap terjadi jika:
- tanggal mulai booking baru berada di dalam rentang booking lama
- tanggal selesai booking baru berada di dalam rentang booking lama
- booking baru sepenuhnya mencakup booking lama
Jika bentrok ditemukan:
- sistem mengembalikan user ke form booking
- sistem menampilkan error bahwa kendaraan tidak tersedia pada rentang tanggal tersebut
## Booking Code
Booking code dibuat otomatis dengan format:
- `BK-YYYYMMDD-XXXXXX`
Contoh:
- `BK-20260421-A1B2C3`
Aturan yang dipakai:
- memakai prefix `BK`
- memakai tanggal hari ini
- memakai random string kapital 6 karakter
- dicek ke database agar unik sebelum dipakai
## Penyimpanan Booking
Jika data valid dan tidak bentrok, sistem menyimpan record booking ke tabel `bookings` dengan data:
- booking_code unik
- customer_id dari user login
- rental_company_id dari vehicle
- vehicle_id dari kendaraan yang dipilih
- pickup_date
- return_date
- pickup_time
- pickup_location
- return_location
- duration_days
- with_driver
- customer_name
- customer_email
- customer_phone
- customer_address
- identity_number
- driver_license_number
- note
- subtotal
- discount_amount
- additional_cost
- total_amount
- booking_status = `waiting_payment`
- payment_status = `unpaid`
## Pembuatan Payment Awal
Setelah booking berhasil dibuat, sistem otomatis membuat data payment awal di tabel `payments`.
Nilai yang disimpan:
- booking_id
- payment_method = `manual_transfer`
- amount = total_amount
- payment_status = `unpaid`
- proof_payment = null
- paid_at = null
- verified_by = null
- verified_at = null
Keputusan ini dipakai karena backend pembayaran penuh belum dibangun, tetapi relasi booking-payment sudah disiapkan dari awal.
## Redirect Setelah Booking Berhasil
Setelah booking berhasil disimpan:
- user diarahkan ke halaman pembayaran placeholder
- notifikasi sukses dikirim ke session
Pendekatan ini dipilih karena paling aman dan paling mudah dikembangkan ke tahap pembayaran berikutnya.
## Partial Blade yang Dipakai
### Booking
- `resources/views/booking/index.blade.php`
- `resources/views/booking/data-kendaraan.blade.php`
- `resources/views/booking/form-data-penyewa.blade.php`
- `resources/views/booking/detail-pemesanan.blade.php`
- `resources/views/booking/promo-voucher.blade.php`
- `resources/views/booking/ringkasan-biaya.blade.php`
- `resources/views/booking/tombol-lanjut-pembayaran.blade.php`
### Detail Mobil
- `resources/views/detail-mobil/tombol-booking.blade.php`
## Penyesuaian Frontend
Frontend booking yang sudah ada tetap dipakai, hanya disambungkan ke backend nyata.
Penyesuaian utama:
- data kendaraan diambil dari vehicle yang dipilih
- data customer login dipakai sebagai default form penyewa
- ringkasan biaya dihitung ulang berdasarkan backend logic
- tombol lanjut pembayaran diubah menjadi tombol submit booking
- promo voucher tetap placeholder karena backend promo belum ada
## Keamanan dan Performa
- flow booking dilindungi middleware `auth` dan `role:customer`
- vehicle divalidasi dengan route model binding `vehicle:slug`
- kendaraan tidak valid diblok dengan `404`
- query bentrok memakai filter status yang jelas
- booking code dicek unik sebelum disimpan
- harga tidak pernah dipercaya dari frontend
- data payment awal selalu dibuat dari transaksi booking yang sama
## Status Pengerjaan
- customer bisa membuka halaman booking dari detail mobil
- guest diarahkan ke login
- hanya customer login yang bisa akses booking
- validasi booking backend sudah aktif
- cek bentrok tanggal sudah aktif
- booking dan payment awal sudah tersimpan otomatis
- pembayaran penuh, upload bukti bayar, dan review masih belum dibuat
## Langkah Menjalankan
1. Pastikan data rental company berstatus `approved` tersedia.
2. Pastikan kendaraan berstatus `active` tersedia.
3. Buka halaman detail mobil dari katalog.
4. Klik tombol booking untuk masuk ke halaman booking.
5. Isi form booking lalu simpan.
## Langkah Berikutnya
Tahap backend berikutnya yang paling masuk akal:
1. membangun backend pembayaran customer
2. menambahkan upload bukti bayar dan verifikasi admin rental
3. menambahkan halaman detail booking customer
4. menambahkan review setelah booking selesai

View File

@ -0,0 +1,304 @@
# Dokumentasi Backend Pembayaran Customer + Verifikasi Admin Rental
Dokumentasi ini menjelaskan implementasi Tahap 5 backend untuk flow pembayaran customer dan verifikasi pembayaran oleh admin rental pada platform rental kendaraan berbasis Laravel.
## Tujuan Tahap Ini
Tahap ini dibuat untuk menyambungkan alur:
- booking customer yang sudah terbentuk
- pembayaran oleh customer pemilik booking
- verifikasi atau penolakan pembayaran oleh admin rental pemilik rental terkait
Fokus utama:
- keamanan akses berdasarkan role dan kepemilikan data
- sinkronisasi status booking dan payment
- upload bukti pembayaran ke storage public
- menyiapkan jembatan ke tahap riwayat booking dan review di fase berikutnya
## Ringkasan Fitur yang Dibuat
### Customer Payment Flow
- customer dapat membuka halaman pembayaran berdasarkan booking code
- customer hanya bisa mengakses booking miliknya sendiri
- customer dapat memilih metode pembayaran
- customer dapat upload bukti pembayaran (jpg/jpeg/png/pdf)
- customer dapat melihat status pembayaran real-time dari data payment
- customer dapat melihat invoice dan bukti transaksi sederhana dari data nyata
### Admin Rental Verification Flow
- admin rental dapat melihat daftar pembayaran booking rental miliknya
- admin rental dapat membuka detail pembayaran per booking
- admin rental dapat verify pembayaran yang statusnya uploaded
- admin rental dapat reject pembayaran dengan alasan wajib
- verifikasi dan penolakan mengubah status payment dan booking secara sinkron
## Aturan Bisnis yang Diterapkan
Aturan yang diimplementasikan pada tahap ini:
1. Customer hanya bisa akses pembayaran booking miliknya sendiri.
2. Admin rental hanya bisa akses pembayaran booking milik rental company miliknya.
3. Upload bukti pembayaran hanya menerima file valid dan ukuran maksimal 5MB.
4. Saat customer upload bukti pembayaran:
- payment_status payment menjadi uploaded
- payment_status booking menjadi uploaded
- booking_status booking menjadi waiting_verification
5. Saat admin rental verify pembayaran:
- payment_status payment menjadi verified
- verified_by dan verified_at diisi
- payment_status booking menjadi verified
- booking_status booking menjadi confirmed
6. Saat admin rental reject pembayaran:
- payment_status payment menjadi rejected
- rejection_note wajib diisi
- payment_status booking menjadi rejected
- booking_status booking menjadi waiting_payment
7. Customer yang pembayaran sudah verified tidak dapat mengubah pembayaran lagi.
8. Jika customer upload ulang setelah rejected:
- file lama dihapus dari storage/public
- file baru disimpan
- status kembali ke jalur verifikasi (uploaded / waiting_verification)
## File yang Ditambahkan
### Controller
- app/Http/Controllers/Customer/PaymentController.php
- app/Http/Controllers/AdminRental/PaymentController.php
### Form Request
- app/Http/Requests/UploadPaymentProofRequest.php
- app/Http/Requests/RejectPaymentRequest.php
### Config
- config/payment_methods.php
### View Customer Pembayaran
- resources/views/pembayaran/index.blade.php
- resources/views/pembayaran/ringkasan-pesanan.blade.php
- resources/views/pembayaran/metode-pembayaran.blade.php
- resources/views/pembayaran/detail-pembayaran.blade.php
- resources/views/pembayaran/upload-bukti-pembayaran.blade.php
- resources/views/pembayaran/status-pembayaran.blade.php
- resources/views/pembayaran/invoice-bukti-transaksi.blade.php
- resources/views/pembayaran/footer.blade.php
- resources/views/pembayaran/print.blade.php
### View Admin Rental Pembayaran
- resources/views/admin-rental/payments/index.blade.php
- resources/views/admin-rental/payments/show.blade.php
## File yang Disesuaikan
- routes/web.php
- app/Http/Controllers/Customer/BookingController.php
- app/Models/Booking.php
- app/Models/Payment.php
- resources/views/components/admin-rental-sidebar.blade.php
## Perubahan Route
### Route Customer Payment
- GET /pembayaran/{booking:booking_code} -> pembayaran.show
- POST /pembayaran/{booking:booking_code}/upload -> pembayaran.upload
- GET /pembayaran/{booking:booking_code}/invoice -> pembayaran.invoice
- GET /pembayaran/{booking:booking_code}/bukti-transaksi -> pembayaran.receipt
Proteksi route:
- middleware auth
- middleware role:customer
- ownership check booking di controller
### Route Admin Rental Verification
- GET /admin-rental/payments -> admin-rental.payments.index
- GET /admin-rental/payments/{booking:booking_code} -> admin-rental.payments.show
- PATCH /admin-rental/payments/{booking:booking_code}/verify -> admin-rental.payments.verify
- PATCH /admin-rental/payments/{booking:booking_code}/reject -> admin-rental.payments.reject
Proteksi route:
- middleware auth
- middleware role:admin_rental
- ownership check booking terhadap rental_company admin di controller
## Perubahan Model
### Booking
- ditambahkan route key berbasis booking_code melalui getRouteKeyName()
- relasi payment dipakai untuk sinkronisasi status
### Payment
- ditambahkan accessor status_label untuk tampilan status di view invoice/detail
## Validasi yang Diterapkan
### UploadPaymentProofRequest
Validasi:
- payment_method wajib dan harus ada di config payment_methods
- proof_payment wajib
- proof_payment harus file
- proof_payment mimes: jpg, jpeg, png, pdf
- proof_payment max 5120KB (5MB)
### RejectPaymentRequest
Validasi:
- rejection_note wajib
- rejection_note max 1000 karakter
## Metode Pembayaran
Metode pembayaran dibuat melalui file konfigurasi:
- config/payment_methods.php
Metode yang disiapkan:
- transfer_bank_bca
- transfer_bank_bni
- transfer_bank_bri
- transfer_bank_mandiri
- ewallet_ovo
- ewallet_gopay
- ewallet_dana
Setiap metode memuat:
- label
- group
- account_name
- account_number
- instruction
Pendekatan ini dipilih karena:
- sederhana
- mudah dipelihara
- mudah diganti ke data dinamis di tahap selanjutnya
## Alur Pembayaran Customer
1. Customer login membuka halaman pembayaran dengan booking code.
2. Sistem cek booking milik customer aktif.
3. Halaman menampilkan:
- ringkasan pesanan nyata dari booking dan vehicle
- pilihan metode pembayaran
- detail pembayaran berdasarkan metode terpilih
- upload bukti pembayaran
- status pembayaran aktual
4. Customer upload bukti pembayaran.
5. Sistem menyimpan file ke storage/public dan update status booking + payment.
6. Customer menunggu verifikasi admin rental.
## Alur Verifikasi Admin Rental
1. Admin rental membuka daftar pembayaran di panel admin rental.
2. Sistem hanya menampilkan booking/payment milik rental company admin tersebut.
3. Admin membuka detail pembayaran dan bukti transfer.
4. Jika status payment uploaded:
- admin bisa verify
- admin bisa reject dengan alasan
5. Sistem update status payment dan booking sesuai aksi admin.
## Sinkronisasi Status Booking dan Payment
### Saat Upload Bukti
- payments.payment_status = uploaded
- payments.paid_at = now()
- bookings.payment_status = uploaded
- bookings.booking_status = waiting_verification
### Saat Verify
- payments.payment_status = verified
- payments.verified_by = admin_rental_login_id
- payments.verified_at = now()
- bookings.payment_status = verified
- bookings.booking_status = confirmed
### Saat Reject
- payments.payment_status = rejected
- payments.rejection_note = alasan wajib
- bookings.payment_status = rejected
- bookings.booking_status = waiting_payment
## Keputusan Saat Payment Rejected
Pendekatan yang dipakai:
- bukti lama tetap ada sampai customer upload ulang
- saat upload ulang, file lama dihapus dari storage/public dan diganti file baru
Alasan:
- menjaga kebersihan storage
- mencegah file orphan
- tetap aman untuk flow upload ulang customer
## Integrasi ke Flow Booking
Setelah booking berhasil dibuat, redirect di booking store diarahkan ke:
- pembayaran.show berdasarkan booking yang baru dibuat
Ini membuat transisi booking -> pembayaran menjadi langsung dan konsisten.
## Perubahan UI yang Tetap Minimal
Frontend pembayaran existing tetap dipakai dan tidak dirombak besar.
Penyesuaian yang dilakukan hanya untuk menghubungkan data nyata dan aksi backend, meliputi:
- binding data booking/payment ke partial pembayaran
- form upload proof dengan action backend nyata
- flash message sukses/error
- status chip berdasarkan data payment nyata
- invoice/bukti transaksi dari data nyata
## Akses Keamanan yang Diterapkan
- customer flow: auth + role:customer + owner booking check
- admin flow: auth + role:admin_rental + owner rental check
- jika akses tidak valid: abort 404 (aman)
## Validasi Hasil Implementasi
Route yang sudah tervalidasi:
- booking route (create/store)
- pembayaran customer route (show/upload/invoice/receipt)
- admin-rental payments route (index/show/verify/reject)
Semua file utama yang disentuh pada tahap ini sudah dicek dan tidak ditemukan error sintaks.
## Catatan Deployment Lokal
Pastikan symlink storage sudah dibuat agar file bukti pembayaran bisa diakses dari browser:
php artisan storage:link
## Langkah Backend Berikutnya
Setelah tahap ini, tahap paling logis adalah:
1. Riwayat booking customer (my bookings).
2. Detail booking customer per status.
3. Flow booking ongoing -> completed.
4. Review customer yang hanya aktif ketika booking completed.

View File

@ -0,0 +1,121 @@
# Dokumentasi Fondasi Backend Rental
Bagian ini menjelaskan struktur backend inti yang sudah dibuat untuk platform rental kendaraan multi-rental, e-booking, e-commerce, dan CRM.
## Tujuan Fondasi Backend
- Menyediakan struktur database inti yang rapi dan siap dikembangkan.
- Menjaga relasi data antar customer, admin rental, super admin, rental company, kendaraan, booking, dan pembayaran.
- Menyimpan data transaksi dengan struktur yang mudah dipakai untuk fitur berikutnya.
## Tabel Inti yang Sudah Dibuat
- `users`
- Menyimpan akun customer, admin_rental, dan super_admin.
- Sudah mendukung kolom `role` dan `phone`.
- `rental_companies`
- Menyimpan data perusahaan rental milik admin rental.
- Menyimpan status verifikasi dari super admin.
- `vehicles`
- Menyimpan data kendaraan milik rental company.
- `vehicle_images`
- Menyimpan galeri gambar kendaraan.
- `bookings`
- Menyimpan transaksi booking customer.
- `payments`
- Menyimpan data pembayaran dan proses verifikasi pembayaran.
## Relasi Antar Tabel
- Satu admin rental memiliki satu rental company.
- Satu rental company memiliki banyak kendaraan.
- Satu kendaraan memiliki banyak gambar.
- Satu customer memiliki banyak booking.
- Satu booking milik satu customer, satu kendaraan, dan satu rental company.
- Satu booking memiliki satu payment.
- Satu payment diverifikasi oleh satu user admin rental.
- Satu rental company diverifikasi oleh satu user super admin.
## Status yang Digunakan
### rental_companies.status_verification
- `pending`
- `approved`
- `rejected`
### vehicles.status
- `active`
- `inactive`
- `maintenance`
### bookings.booking_status
- `pending`
- `waiting_payment`
- `waiting_verification`
- `confirmed`
- `ongoing`
- `completed`
- `cancelled`
### bookings.payment_status
- `unpaid`
- `uploaded`
- `verified`
- `rejected`
### payments.payment_status
- `unpaid`
- `uploaded`
- `verified`
- `rejected`
## File Backend yang Ditambahkan
### Model
- `app/Models/RentalCompany.php`
- `app/Models/Vehicle.php`
- `app/Models/VehicleImage.php`
- `app/Models/Booking.php`
- `app/Models/Payment.php`
- Update model user: `app/Models/User.php`
### Migration
- `database/migrations/2026_04_21_000004_add_phone_to_users_table.php`
- `database/migrations/2026_04_21_000005_create_rental_companies_table.php`
- `database/migrations/2026_04_21_000006_create_vehicles_table.php`
- `database/migrations/2026_04_21_000007_create_vehicle_images_table.php`
- `database/migrations/2026_04_21_000008_create_bookings_table.php`
- `database/migrations/2026_04_21_000009_create_payments_table.php`
## Urutan Migration
Urutan migration dibuat agar foreign key aman saat dijalankan:
1. Tambah kolom `role` pada `users`
2. Tambah kolom `phone` pada `users`
3. Buat `rental_companies`
4. Buat `vehicles`
5. Buat `vehicle_images`
6. Buat `bookings`
7. Buat `payments`
## Aturan Bisnis yang Sudah Siap Ditangani
- Customer wajib login sebelum booking.
- Satu kendaraan tidak boleh dibooking pada tanggal yang sama.
- Pembayaran diverifikasi oleh admin rental.
- Customer hanya bisa memberi ulasan jika booking sudah completed.
- Rental yang belum diverifikasi tidak boleh tampil di platform customer.
## Status Pengerjaan
- Migration sudah dijalankan dan tabel inti sudah terbentuk.
- Model dan relasi dasar sudah disiapkan.
- Fondasi ini siap dipakai untuk controller, service, middleware, dan fitur frontend berikutnya.
## Langkah Berikutnya
1. Buat seeder data contoh rental company, kendaraan, dan booking.
2. Tambahkan validasi booking agar tanggal kendaraan tidak bentrok.
3. Tambahkan query/filter agar customer hanya melihat rental company yang sudah approved.
4. Bangun controller dan halaman admin/customer di atas struktur data ini.

View File

@ -0,0 +1,234 @@
# Dokumentasi Backend Katalog Customer + Detail Mobil
Dokumentasi ini menjelaskan tahap 3 backend yang sudah dibuat untuk halaman **Katalog Customer** dan **Detail Mobil**.
## Tujuan Fitur
Tahap ini dibuat agar halaman publik/customer tidak lagi memakai data dummy, melainkan membaca data kendaraan nyata dari database.
Fokus fitur ini:
- menampilkan daftar kendaraan dari database
- menampilkan detail mobil dari database
- menjaga filter data agar hanya kendaraan yang layak tampil yang muncul ke customer
- tetap mempertahankan desain frontend yang sudah ada
## Aturan Bisnis yang Diterapkan
- Katalog dan detail mobil boleh dilihat publik/customer.
- Customer wajib login sebelum booking, tetapi booking belum dibangun di tahap ini.
- Rental yang belum diverifikasi tidak boleh tampil ke customer.
- Hanya kendaraan dengan status `active` yang boleh tampil di katalog.
- Detail mobil hanya boleh diakses jika kendaraan aktif dan rental company sudah `approved`.
- Jika data tidak valid, sistem mengembalikan `404`.
## Route yang Dibuat
Route public/customer yang digunakan:
- `GET /katalog``katalog.index`
- `GET /katalog/{vehicle:slug}``katalog.show`
Catatan:
- Route detail mobil lama yang statis tidak lagi dipakai.
- Detail mobil sekarang mengikuti slug kendaraan dari database.
## File Backend yang Ditambahkan
### Controller
- `app/Http/Controllers/Customer/KatalogController.php`
### Model yang Disesuaikan
- `app/Models/Vehicle.php`
- `app/Models/RentalCompany.php`
- `app/Models/VehicleImage.php`
### Route Update
- `routes/web.php`
## Relasi Data yang Dipakai
Tahap ini membaca data dari relasi berikut:
- `Vehicle` belongsTo `RentalCompany`
- `Vehicle` hasMany `VehicleImage`
- `Vehicle` punya relasi `primaryImage` untuk foto utama
- `RentalCompany` scope `approved()` untuk rental yang sudah diverifikasi
- `Vehicle` scope `active()` untuk kendaraan aktif
- `Vehicle` scope `visibleToCustomers()` untuk kendaraan yang layak tampil
## Alur Data Katalog
### 1. Halaman Katalog
- Customer membuka `/katalog`.
- Controller mengambil kendaraan yang:
- status-nya `active`
- rental company-nya `approved`
- Data di-load dengan eager loading untuk:
- `rentalCompany`
- `images`
- Hasil ditampilkan dengan pagination.
- Query string tetap dipertahankan saat pindah halaman.
### 2. Filter dan Sorting
Fitur filter yang disiapkan:
- pencarian berdasarkan nama kendaraan, brand, atau kategori
- filter category
- filter transmission
- filter fuel_type
- filter seat_capacity
- filter harga minimum
- filter harga maksimum
- sorting:
- terbaru
- harga terendah
- harga tertinggi
### 3. List Kendaraan
Setiap kartu kendaraan menampilkan:
- foto utama
- nama kendaraan
- nama rental
- category
- transmission
- fuel_type
- seat_capacity
- price_per_day
- status sederhana: `Tersedia`
- tombol lihat detail
Jika belum ada gambar utama, sistem dapat memakai gambar galeri pertama sebagai fallback.
## Alur Detail Mobil
### 1. Halaman Detail
- Customer membuka `/katalog/{vehicle:slug}`.
- Controller mencari kendaraan berdasarkan slug.
- Sistem memuat relasi:
- `rentalCompany`
- `images`
- Detail hanya tampil jika:
- kendaraan status `active`
- rental company status `approved`
- Jika tidak valid, sistem mengembalikan `404`.
### 2. Informasi yang Ditampilkan
Halaman detail menampilkan:
- nama kendaraan
- brand
- type
- category
- year
- transmission
- fuel_type
- seat_capacity
- luggage_capacity
- color
- price_per_day
- description
- terms_conditions
- main_image
- gallery images
- informasi rental:
- company_name
- city
- phone
- email
- logo jika ada
### 3. Tombol Booking
- Jika guest, tombol booking diarahkan ke halaman login.
- Jika user login sebagai customer, tombol booking diarahkan ke placeholder route booking.
- Jika login bukan customer, tombol diarahkan ke home sebagai langkah aman.
## Partial Blade yang Dipakai
### Katalog
- `resources/views/katalog/index.blade.php`
- `resources/views/katalog/search-bar.blade.php`
- `resources/views/katalog/filter-kendaraan.blade.php`
- `resources/views/katalog/sorting.blade.php`
- `resources/views/katalog/status-ketersediaan.blade.php`
- `resources/views/katalog/daftar-kendaraan.blade.php`
- `resources/views/katalog/pagination.blade.php`
### Detail Mobil
- `resources/views/detail-mobil/index.blade.php`
- `resources/views/detail-mobil/galeri-foto.blade.php`
- `resources/views/detail-mobil/informasi-utama.blade.php`
- `resources/views/detail-mobil/spesifikasi.blade.php`
- `resources/views/detail-mobil/deskripsi.blade.php`
- `resources/views/detail-mobil/syarat-dan-ketentuan.blade.php`
- `resources/views/detail-mobil/informasi-rental.blade.php`
- `resources/views/detail-mobil/rating-dan-ulasan.blade.php`
- `resources/views/detail-mobil/tombol-booking.blade.php`
## Penyesuaian Frontend
Frontend yang sudah ada tetap dipakai, hanya data dummy diganti dengan data nyata dari database.
Penyesuaian utama:
- link katalog di navbar diarahkan ke route baru `katalog.index`
- tombol lihat detail di katalog diarahkan ke route `katalog.show`
- tombol booking di detail mobil memakai pendekatan aman berdasarkan status login
- review tetap placeholder karena backend review belum dibuat
## Keamanan dan Performa
- Query katalog memakai eager loading agar tidak terjadi N+1 query.
- Data customer hanya menampilkan kendaraan dari rental yang sudah approved.
- Detail mobil yang tidak valid diblok dengan `404`.
- Filter query dibatasi agar input tetap aman dan masuk akal.
- Sorting dibatasi pada nilai yang diizinkan.
## Fungsi Reusable yang Ditambahkan
### Pada Model `Vehicle`
- `scopeActive()`
- `scopeVisibleToCustomers()`
- `primaryImage()`
### Pada Model `RentalCompany`
- `scopeApproved()`
Fungsi ini membuat query tahap berikutnya lebih mudah dikembangkan.
## Status Pengerjaan
- Katalog customer sudah membaca data nyata dari database.
- Detail mobil sudah membaca data nyata dari database.
- Filter, sorting, dan pagination sudah disiapkan.
- Tombol detail dan booking sudah diarahkan secara aman.
- Backend booking, pembayaran, dan review masih belum dibuat di tahap ini.
## Langkah Menjalankan
1. Pastikan migration inti sudah dijalankan.
2. Pastikan ada data rental company dengan status `approved`.
3. Pastikan ada kendaraan dengan status `active`.
4. Buka halaman katalog:
```text
http://127.0.0.1:8000/katalog
```
5. Buka detail mobil dari tombol lihat detail di katalog.
## Langkah Berikutnya
Tahap backend berikutnya yang paling masuk akal:
1. membangun backend booking customer
2. menambahkan validasi bentrok tanggal kendaraan
3. membangun backend pembayaran dan verifikasi admin rental
4. menambahkan review setelah booking selesai

View File

@ -0,0 +1,120 @@
# Dokumentasi Setup WebRental dari File RAR ke Localhost
Dokumen ini menjelaskan cara menjalankan project WebRental di komputer lokal, dimulai dari file RAR yang sudah diterima sampai website aktif lewat `php artisan serve`.
## Kebutuhan Awal
Pastikan perangkat Anda sudah memiliki:
- PHP
- Composer
- Node.js dan NPM
- MySQL atau MariaDB
- Web server lokal seperti Laragon
## Langkah Setup
### 1. Ekstrak file RAR
Jika project dikirim dalam bentuk file `.rar`, lakukan ekstrak terlebih dahulu.
Contoh langkah:
- klik kanan file `.rar`
- pilih `Extract Here` atau `Extract to...`
- pindahkan hasil ekstrak ke folder kerja, misalnya folder Laragon `www`
Setelah diekstrak, pastikan folder project berisi file seperti:
- `artisan`
- `composer.json`
- `package.json`
- folder `app`, `routes`, `resources`, `database`
### 2. Install dependency
Jalankan perintah berikut di folder project:
```bash
composer install
npm install
```
### 3. Siapkan file environment
- Duplikat file `.env.example` menjadi `.env`
- Atur konfigurasi database di dalam file `.env`
Contoh konfigurasi database:
```env
DB_DATABASE=webrental
DB_USERNAME=root
DB_PASSWORD=
```
### 4. Generate application key
```bash
php artisan key:generate
```
### 5. Jalankan migration dan seeder
```bash
php artisan migrate
php artisan db:seed
```
### 6. Build asset frontend
Jika ingin build sekali:
```bash
npm run build
```
Jika ingin mode development:
```bash
npm run dev
```
### 7. Jalankan project
```bash
php artisan serve
```
### 8. Buka di browser
```text
http://127.0.0.1:8000
```
## Jika Ada Perubahan Tidak Muncul
Jika tampilan belum berubah atau cache masih tersimpan, jalankan:
```bash
php artisan view:clear
php artisan cache:clear
```
## Ringkasan Cepat
Urutan paling cepat untuk setup dari file RAR adalah:
1. ekstrak file `.rar`
2. pindahkan folder project ke lokasi kerja
3. jalankan `composer install`
4. jalankan `npm install`
5. copy `.env.example` ke `.env`
6. isi database di `.env`
7. jalankan `php artisan key:generate`
8. jalankan `php artisan migrate`
9. jalankan `php artisan db:seed`
10. jalankan `npm run build` atau `npm run dev`
11. jalankan `php artisan serve`
Setelah itu website bisa dibuka di localhost.

View File

@ -0,0 +1,310 @@
# Dokumentasi Tahap 6 Backend
## Riwayat Booking Customer + Daftar Booking Admin Rental + Status Transaksi Lengkap
Dokumentasi ini menjelaskan implementasi Tahap 6 backend yang berfokus pada:
- riwayat booking milik customer
- daftar booking milik admin rental
- detail transaksi booking dengan status booking dan payment yang konsisten
- transisi status operasional booking oleh admin rental
## Tujuan Implementasi
Tahap ini dibuat agar:
1. Customer dapat melihat seluruh booking miliknya sendiri.
2. Customer dapat memfilter dan membuka detail booking.
3. Admin rental dapat melihat booking rental miliknya sendiri.
4. Admin rental dapat melakukan transisi status operasional booking secara aman.
5. Status booking dan payment tetap sinkron serta siap menjadi fondasi fitur review tahap berikutnya.
## Aturan Bisnis yang Diterapkan
Aturan yang dijaga dalam implementasi ini:
- customer hanya bisa melihat booking miliknya sendiri
- admin rental hanya bisa melihat booking milik rental_company miliknya sendiri
- transisi operasional dibatasi:
- confirmed -> ongoing (hanya jika payment_status = verified)
- ongoing -> completed
- cancel booking hanya diizinkan untuk status:
- waiting_payment
- waiting_verification
- confirmed
- booking status completed tidak bisa dicancel
- payment_status tidak diubah sembarangan saat cancel
- jika payment sudah verified lalu booking dicancel, payment_status tetap verified (refund belum dibahas di tahap ini)
## Daftar File yang Ditambahkan
### Controller
- app/Http/Controllers/Customer/MyBookingController.php
- app/Http/Controllers/AdminRental/BookingController.php
### Request Validation
- app/Http/Requests/CancelBookingRequest.php
### View Customer
- resources/views/customer/bookings/index.blade.php
- resources/views/customer/bookings/show.blade.php
### View Admin Rental
- resources/views/admin-rental/bookings/index.blade.php
- resources/views/admin-rental/bookings/show.blade.php
## Daftar File yang Disesuaikan
- routes/web.php
- app/Models/Booking.php
- resources/views/components/admin-rental-sidebar.blade.php
## Perubahan Routes
### Customer Bookings
- GET /my-bookings -> customer.bookings.index
- GET /my-bookings/{booking:booking_code} -> customer.bookings.show
### Admin Rental Bookings
- GET /admin-rental/bookings -> admin-rental.bookings.index
- GET /admin-rental/bookings/{booking:booking_code} -> admin-rental.bookings.show
- PATCH /admin-rental/bookings/{booking:booking_code}/mark-ongoing -> admin-rental.bookings.mark-ongoing
- PATCH /admin-rental/bookings/{booking:booking_code}/mark-completed -> admin-rental.bookings.mark-completed
- PATCH /admin-rental/bookings/{booking:booking_code}/cancel -> admin-rental.bookings.cancel
Semua route diproteksi dengan middleware role yang sesuai:
- customer flow: auth + role:customer
- admin flow: auth + role:admin_rental
## Detail Implementasi Controller
## 1) Customer - MyBookingController
### index()
Fitur:
- query booking hanya untuk customer login (where customer_id = auth user)
- filter booking_status
- filter payment_status
- search booking_code atau nama kendaraan
- eager loading:
- vehicle
- vehicle.primaryImage
- vehicle.rentalCompany
- payment
- pagination + withQueryString
### show()
Fitur:
- cek ownership booking milik customer login
- jika bukan miliknya -> abort 404
- tampilkan detail lengkap booking + payment + kendaraan + rental
## 2) Admin Rental - BookingController
### index()
Fitur:
- query booking hanya milik rental company admin login
- filter booking_status
- filter payment_status
- search booking_code, customer_name, atau nama kendaraan
- eager loading:
- vehicle
- customer
- payment
- pagination + withQueryString
### show()
Fitur:
- cek booking milik rental company admin login
- jika bukan miliknya -> abort 404
- tampilkan detail booking + data customer + payment + bukti pembayaran
### markOngoing()
Validasi transisi:
- booking_status harus confirmed
- payment_status harus verified
Jika valid:
- booking_status -> ongoing
### markCompleted()
Validasi transisi:
- booking_status harus ongoing
Jika valid:
- booking_status -> completed
### cancel()
Validasi transisi:
- hanya boleh jika booking_status saat ini:
- waiting_payment
- waiting_verification
- confirmed
Jika valid:
- booking_status -> cancelled
- payment_status tidak diubah
- cancel_reason (opsional) ditambahkan ke note booking
## Penyesuaian Model Booking
Model Booking ditambahkan helper agar status bisa konsisten dipakai di UI:
- statusOptions()
- paymentStatusOptions()
- getBookingStatusLabelAttribute()
- getPaymentStatusLabelAttribute()
Manfaat:
- label status lebih rapi dan konsisten
- dipakai ulang di halaman customer dan admin tanpa duplikasi mapping
## Blade Customer
## customer/bookings/index.blade.php
Menampilkan:
- daftar booking customer
- booking_code
- foto kendaraan
- nama kendaraan
- nama rental
- tanggal sewa
- durasi
- total_amount
- booking_status (badge)
- payment_status (badge)
- tombol detail
- filter dan search
## customer/bookings/show.blade.php
Menampilkan:
- ringkasan booking lengkap
- data kendaraan, rental, penyewa
- jadwal sewa
- rincian biaya
- status booking dan payment
- metode pembayaran
- bukti pembayaran jika ada
- rejection_note jika pembayaran ditolak
- timeline status transaksi
Kondisi khusus:
- jika payment_status = rejected: tampilkan pesan penolakan + tombol upload ulang ke flow pembayaran
- jika booking_status = completed: tampilkan placeholder bahwa fitur review akan tersedia di tahap selanjutnya
## Blade Admin Rental
## admin-rental/bookings/index.blade.php
Menampilkan:
- daftar booking milik rental
- filter booking_status, payment_status, search
- booking_code
- customer
- kendaraan
- pickup / return
- total
- status booking
- status payment
- tanggal booking
- tombol detail
## admin-rental/bookings/show.blade.php
Menampilkan:
- detail booking lengkap
- data payment + bukti pembayaran
- timeline transaksi
- tombol aksi status operasional dengan kondisi tampil berdasarkan status saat ini:
- mark ongoing
- mark completed
- cancel booking
## Timeline Status Transaksi
Pada halaman detail customer dan admin ditampilkan status flow transaksi berbasis kondisi saat ini:
- Booking Dibuat
- Menunggu Pembayaran
- Menunggu Verifikasi
- Dikonfirmasi
- Sedang Berjalan
- Selesai
- Dibatalkan
Tahap ini belum menggunakan tabel history terpisah. Timeline bersifat representasi status state saat ini.
## Validasi Keamanan
Yang dipastikan:
- customer tidak dapat mengakses booking milik user lain
- admin rental tidak dapat mengakses booking milik rental lain
- transisi status tidak valid ditolak dengan pesan error
- payment_status tidak dimodifikasi sembarangan saat update operasional
## Integrasi Navigasi Admin
Sidebar admin rental disesuaikan:
- menu Data Booking diarahkan ke route admin-rental.bookings.index
## Validasi Hasil
Route yang sudah dicek dan aktif:
- my-bookings (index, show)
- admin-rental/bookings (index, show, mark-ongoing, mark-completed, cancel)
File utama yang diubah pada tahap ini telah dicek dan tidak ada error sintaks.
## Dampak ke Tahap Berikutnya
Tahap ini sudah menyiapkan fondasi untuk:
1. fitur review/ulasan customer berbasis booking completed
2. analitik lifecycle booking (confirmed -> ongoing -> completed)
3. fitur CRM di tahap lanjutan berbasis histori transaksi customer
## Catatan Scope Tahap Ini
Hal yang sengaja belum dibuat:
- review
- promo/CRM aktif
- refund flow
- audit log transaksi terpisah
Semua poin di atas ditunda sesuai batasan tahap implementasi.

View File

@ -0,0 +1,275 @@
# DOKUMENTASI TAHAP 7
## Backend Review/Ulasan Customer
Tanggal: 21 April 2026
Project: WebRental (Laravel)
---
## 1. Tujuan Tahap Ini
Tahap ini fokus pada alur:
completed booking -> customer membuat review -> review tampil di detail mobil.
Cakupan yang dikerjakan:
1. Customer bisa membuat ulasan hanya untuk booking miliknya yang sudah completed.
2. Satu booking hanya boleh punya satu review.
3. Riwayat booking customer menampilkan status review dan tombol Beri Ulasan saat memenuhi syarat.
4. Halaman detail mobil menampilkan data review nyata dari database (rata-rata, jumlah, daftar ulasan).
5. Admin rental bisa melihat daftar review yang masuk untuk rental miliknya (list sederhana).
Cakupan yang tidak dikerjakan:
1. Promo/CRM.
2. Super admin module review.
3. Balas review.
4. Edit/hapus review.
---
## 2. Implementasi Database
### 2.1 Migration Baru
Dibuat tabel reviews dengan relasi kuat ke booking, customer, vehicle, rental_company.
File:
- database/migrations/2026_04_21_000010_create_reviews_table.php
Struktur penting:
1. booking_id dibuat unique untuk menjamin satu booking maksimal satu review.
2. rating disimpan sebagai unsignedTinyInteger.
3. review dibuat nullable agar customer tetap bisa memberi rating walau tanpa komentar.
4. Index tambahan untuk query listing dan ringkasan rating.
---
## 3. Model dan Relasi
### 3.1 Model Baru
File:
- app/Models/Review.php
Relasi:
1. Review belongsTo Booking
2. Review belongsTo User sebagai customer
3. Review belongsTo Vehicle
4. Review belongsTo RentalCompany
### 3.2 Penyesuaian Model Existing
File:
- app/Models/Booking.php
- Tambah relasi hasOne review()
- app/Models/Vehicle.php
- Tambah relasi hasMany reviews()
- app/Models/RentalCompany.php
- Tambah relasi hasMany reviews()
- app/Models/User.php
- Tambah relasi hasMany reviews() dengan foreign key customer_id
---
## 4. Controller dan Request
### 4.1 Customer Review Controller
File:
- app/Http/Controllers/Customer/ReviewController.php
Method:
1. create(Booking $booking)
2. store(StoreReviewRequest $request, Booking $booking)
Aturan yang ditegakkan di controller:
1. Booking harus milik customer login. Jika bukan, response 404.
2. Booking harus status completed.
3. Booking tidak boleh sudah punya review.
4. Data relasi review diambil dari booking valid di server, bukan dari input frontend.
Setelah store sukses:
- Redirect ke detail booking customer dengan flash success.
### 4.2 Form Request
File:
- app/Http/Requests/StoreReviewRequest.php
Validasi:
1. rating: required, integer, min 1, max 5.
2. review: nullable, string, max 1000.
Alasan review nullable:
- User bisa cepat memberikan rating tanpa wajib menulis komentar panjang.
### 4.3 Admin Rental Review Controller
File:
- app/Http/Controllers/AdminRental/ReviewController.php
Method:
1. index()
Fungsi:
1. Menampilkan review yang hanya milik rental company dari admin login.
2. Query menggunakan filter rental_company_id agar admin tidak melihat data rental lain.
---
## 5. Routing
File:
- routes/web.php
Route customer review:
1. GET /my-bookings/{booking:booking_code}/review -> customer.reviews.create
2. POST /my-bookings/{booking:booking_code}/review -> customer.reviews.store
Proteksi:
- middleware auth + role:customer
Route admin rental review:
1. GET /admin-rental/reviews -> admin-rental.reviews.index
Proteksi:
- middleware auth + role:admin_rental
---
## 6. Perubahan Query Data
### 6.1 Detail Mobil
File:
- app/Http/Controllers/Customer/KatalogController.php
Pada method show(Vehicle $vehicle):
1. Ambil hanya review kendaraan yang terkait booking completed.
2. Hitung average rating.
3. Hitung total review.
4. Hitung breakdown jumlah bintang 1 sampai 5.
5. Ambil daftar review terbaru dengan eager loading customer.
Hasil dikirim ke view detail mobil:
- reviews
- averageRating
- totalReviews
- ratingBreakdown
### 6.2 Riwayat Booking Customer
File:
- app/Http/Controllers/Customer/MyBookingController.php
Perubahan:
1. Eager load relasi review pada index.
2. Eager load relasi review pada show.
Tujuan:
- Menghindari N+1 saat cek status sudah direview atau belum.
---
## 7. Perubahan Blade
### 7.1 Halaman Form Review Customer
File baru:
- resources/views/customer/reviews/create.blade.php
Isi utama:
1. Info kendaraan, rental, booking code, tanggal sewa.
2. Input rating 1-5.
3. Textarea komentar (opsional).
4. Submit ke customer.reviews.store.
### 7.2 Riwayat Booking Customer (Index)
File:
- resources/views/customer/bookings/index.blade.php
Perubahan:
1. Tambah kolom Ulasan.
2. Jika completed + belum direview -> status Belum Direview.
3. Jika completed + sudah direview -> status Sudah Direview.
4. Tombol Beri Ulasan tampil hanya jika completed dan belum direview.
### 7.3 Detail Booking Customer (Show)
File:
- resources/views/customer/bookings/show.blade.php
Perubahan:
1. Jika completed + belum direview -> tampil CTA Beri Ulasan.
2. Jika completed + sudah direview -> tampil ringkasan ulasan yang sudah dibuat.
### 7.4 Detail Mobil - Section Ulasan
File:
- resources/views/detail-mobil/rating-dan-ulasan.blade.php
Perubahan:
1. Ganti placeholder menjadi data review nyata.
2. Tampil rata-rata rating dan total review.
3. Tampil breakdown bintang.
4. Tampil daftar review terbaru dengan nama customer disamarkan sebagian.
5. Tampil empty state jika belum ada review.
Catatan teknis:
- Untuk menghindari error kompilasi style dinamis pada Blade, visual bar distribusi memakai elemen progress.
### 7.5 Detail Mobil - Informasi Utama
File:
- resources/views/detail-mobil/informasi-utama.blade.php
Perubahan:
1. Tambah ringkasan rating sederhana (avg dan jumlah review).
### 7.6 Admin Rental - List Ulasan
File baru:
- resources/views/admin-rental/reviews/index.blade.php
Perubahan:
1. Tabel sederhana berisi tanggal, kendaraan, customer, booking, rating, cuplikan ulasan.
### 7.7 Sidebar Admin Rental
File:
- resources/views/components/admin-rental-sidebar.blade.php
Perubahan:
1. Tambah menu Data Ulasan ke halaman admin-rental.reviews.index.
---
## 8. Validasi Aturan Bisnis yang Dipenuhi
1. Customer hanya bisa review booking miliknya sendiri.
2. Customer hanya bisa review jika booking completed.
3. Satu booking hanya satu review (double guard: unique DB + guard controller).
4. Data review terhubung ke booking, vehicle, rental_company, customer.
5. Detail mobil menampilkan review nyata dari database.
6. Admin rental hanya melihat review rental miliknya.
---
## 9. Verifikasi yang Dilakukan
Perintah yang dijalankan:
1. php artisan route:list --path=my-bookings; php artisan route:list --path=review; php artisan route:list --path=admin-rental/reviews
2. php artisan route:list --path=admin-rental/reviews
Hasil:
1. Route customer review create/store terdaftar.
2. Route admin rental reviews index terdaftar.
3. Pemeriksaan error file penting tidak menemukan error setelah perbaikan akhir.
---
## 10. Ringkasan Alur End-to-End
1. Booking customer mencapai status completed.
2. Di riwayat booking/detail booking muncul tombol Beri Ulasan.
3. Customer submit rating dan komentar opsional.
4. Backend validasi kepemilikan booking, status completed, dan review belum ada.
5. Review disimpan dan terhubung ke booking, vehicle, rental_company, customer.
6. Detail mobil otomatis menampilkan ringkasan dan daftar ulasan terbaru.
7. Admin rental melihat ulasan masuk pada menu Data Ulasan.
---
## 11. Langkah Lanjutan yang Disarankan
1. Tambah unit/feature test untuk skenario akses review (ownership, completed, duplicate).
2. Tambah endpoint agregasi rating jika nanti dipakai modul promo/CRM.
3. Tambah moderation sederhana untuk kualitas konten ulasan sebelum tahap promo.

View File

@ -0,0 +1,446 @@
# DOKUMENTASI TAHAP 8: BACKEND PROMO + CRM
## Status Implementasi
**COMPLETE** - Semua fitur Tahap 8 sudah diimplementasikan dan siap testing.
---
## 1. Overview Tahap 8
Tahap 8 menambahkan dua modul besar:
1. **Promo Module**: CRUD promo per rental company dengan validasi ketat, integrasi booking flow, dan perhitungan diskon otomatis
2. **CRM Module**: Dashboard customer dengan tracking loyalitas, riwayat booking, reviews, dan targeting promo
### Aturan Bisnis Utama
- **Loyalitas**: Customer dianggap setia jika telah menyelesaikan ≥2 booking di rental yang sama
- **Promo Validation**: Validasi ketat di backend dengan transaction locking untuk prevent race condition
- **Diskon Calculation**: 100% dihitung di backend, frontend hanya menampilkan estimasi
- **Access Control**: Admin rental hanya bisa mengelola promo & customer miliknya
---
## 2. Database Layer
### Migrations Created
#### 1. `database/migrations/2026_04_21_000011_create_promos_table.php`
```sql
CREATE TABLE promos (
id BIGINT PRIMARY KEY
rental_company_id BIGINT (FK to rental_companies)
title VARCHAR (255)
promo_code VARCHAR (50) UNIQUE INDEX
description TEXT NULLABLE
discount_type ENUM ('percent', 'fixed')
discount_value DECIMAL (10,2)
min_transaction DECIMAL (12,2) NULLABLE
start_date DATETIME
end_date DATETIME
quota INT NULLABLE (unlimited jika null)
used_count INT DEFAULT 0
loyal_only BOOLEAN DEFAULT 0
status ENUM ('active', 'inactive')
timestamps
INDEXES:
- (rental_company_id, status)
- (rental_company_id, start_date, end_date)
)
```
#### 2. `database/migrations/2026_04_21_000012_add_promo_foreign_key_to_bookings_table.php`
```sql
ALTER TABLE bookings ADD COLUMN promo_id BIGINT NULLABLE
ADD FOREIGN KEY (promo_id) REFERENCES promos(id) ON DELETE SET NULL
```
---
## 3. Model Layer
### Promo Model
**Location**: `app/Models/Promo.php`
**Constants**:
```php
DISCOUNT_PERCENT = 'percent'
DISCOUNT_FIXED = 'fixed'
STATUS_ACTIVE = 'active'
STATUS_INACTIVE = 'inactive'
```
**Relations**:
- `rentalCompany()` - BelongsTo RentalCompany
- `bookings()` - HasMany Booking
**Accessor**:
- `discount_label` - Format display diskon (e.g., "50%" atau "Rp 100.000")
### Booking Model Update
**Added**:
- `promo()` - BelongsTo Promo (nullable)
- `promo_id` - Foreign key field (dalam fillable)
### RentalCompany Model Update
**Added**:
- `promos()` - HasMany Promo
- `bookings()` - HasMany Booking
---
## 4. Service Layer
### PromoService
**Location**: `app/Services/PromoService.php`
**Key Methods**:
#### `getVisiblePromosForBooking(string $promoCode, int $rentalCompanyId, int $customerId, float $subtotal, bool $lockForUpdate = false)`
- Filter promo yang bisa digunakan customer
- Return collection dengan attributes:
- `can_use` (boolean)
- `cannot_use_reason` (string, jika can_use = false)
- `estimated_discount` (float)
- Validasi: status active, dalam periode, belum expired, loyal_only check, quota check
#### `resolvePromoForBooking(string $promoCode, int $rentalCompanyId, int $customerId, float $subtotal, bool $lockForUpdate = false)`
- Strict validation dengan transaction lock jika `$lockForUpdate = true`
- Throw `ValidationException` jika invalid
- Return array: `['promo' => Promo, 'discount_amount' => float]`
#### `calculateDiscount(Promo $promo, float $subtotal)`
- Handle percent vs fixed discount
- Cap diskon tidak boleh melebihi subtotal
#### `isLoyalCustomer(int $customerId, int $rentalCompanyId)`
- Check apakah customer telah menyelesaikan ≥ 2 bookings di rental
- `LOYAL_COMPLETED_THRESHOLD = 2`
---
## 5. Request Validation Layer
### StorePromoRequest
**Location**: `app/Http/Requests/StorePromoRequest.php`
**Validasi**:
- `title`: required, string, max 255
- `promo_code`: required, alpha_dash, max 50, unique:promos (per rental_company)
- `description`: nullable, string
- `discount_type`: required, in:percent,fixed
- `discount_value`: required, numeric, > 0
- `min_transaction`: nullable, numeric, >= 0
- `start_date`: required, date_format:Y-m-d H:i, before:end_date
- `end_date`: required, date_format:Y-m-d H:i
- `quota`: nullable, integer, >= 1
- `loyal_only`: nullable, boolean
- `status`: required, in:active,inactive
### UpdatePromoRequest
- Sama seperti StorePromoRequest
- `promo_code` unique ignore current promo id
### StoreBookingRequest Update
- `promo_code`: nullable, string, max 50
- Validasi di controller melalui PromoService
---
## 6. Controller Layer
### PromoController
**Location**: `app/Http/Controllers/AdminRental/PromoController.php`
**Methods** (7 total):
1. `index()` - List dengan filter search & status, pagination 10 item
2. `create()` - Show form create
3. `store()` - Save promo baru, set rental_company_id dari logged-in admin
4. `edit()` - Show form edit
5. `update()` - Update promo (promo_code read-only)
6. `destroy()` - Soft/hard delete promo
7. `toggle()` - Toggle status active/inactive
**Access Control**:
- `getRentalCompanyOrAbort()` - Ensure admin punya rental company
- `ensurePromoBelongsToRental()` - Check promo milik rental admin
**Data Normalization**:
- Promo code otomatis uppercase saat save
### CustomerController
**Location**: `app/Http/Controllers/AdminRental/CustomerController.php`
**Methods** (2 public):
1. `index()` - List customer dengan aggregates dan filter loyal/non-loyal
2. `show()` - Detail customer dengan booking history, reviews, vehicle history
**Index Query Features**:
- `withCount(['bookings as booking_count', 'bookings as completed_booking_count'])`
- `withSum('bookings as total_transaction_amount', 'total_amount')`
- `withMax('bookings as last_booking_at', 'created_at')`
- `withAvg('reviews as average_rating_given', 'rating')`
- Filter loyal: `whereIn($loyalCustomerIdSubQuery)` via robust subquery
- Filter non-loyal: `whereNotIn($loyalCustomerIdSubQuery)`
- Search: name & email
- Pagination: 10 item per halaman
**Show Query Features**:
- Bookings: eager load vehicle, payment, review, promo (paginated 8 per halaman)
- Reviews: all reviews customer di rental (sorted latest)
- Vehicles: distinct list of vehicles customer pernah rental
- Stats: bookingCount, completedCount, totalTransactionAmount, lastBookingDate
---
## 7. Route Registration
**Location**: `routes/web.php`
### Promo Routes (7 routes)
```
GET /admin-rental/promos → PromoController@index (admin-rental.promos.index)
GET /admin-rental/promos/create → PromoController@create (admin-rental.promos.create)
POST /admin-rental/promos → PromoController@store (admin-rental.promos.store)
GET /admin-rental/promos/{promo}/edit → PromoController@edit (admin-rental.promos.edit)
PUT /admin-rental/promos/{promo} → PromoController@update (admin-rental.promos.update)
DELETE /admin-rental/promos/{promo} → PromoController@destroy (admin-rental.promos.destroy)
PATCH /admin-rental/promos/{promo}/toggle → PromoController@toggle (admin-rental.promos.toggle)
```
### Customer CRM Routes (2 routes)
```
GET /admin-rental/customers → CustomerController@index (admin-rental.customers.index)
GET /admin-rental/customers/{customer} → CustomerController@show (admin-rental.customers.show)
```
### Booking Integration
- StoreBookingRequest validation include promo_code
- BookingController@store inject PromoService
- In transaction: call resolvePromoForBooking with lock
- Increment promo.used_count saat booking saved
- Handle ValidationException → return ke form with errors
---
## 8. View Layer
### Admin Promo CRUD (4 files)
#### `resources/views/admin-rental/promos/index.blade.php`
- Table dengan kolom: code, title, discount type/value, min transaction, kuota, terpakai, periode, loyal only, status, aksi
- Search by title & promo code
- Filter by status (active/inactive)
- Pagination dengan withQueryString()
- Edit & Delete buttons per row
#### `resources/views/admin-rental/promos/form.blade.php`
- Reusable form untuk create & edit
- Fields: title, promo_code (read-only saat edit), discount_type, discount_value, min_transaction, start_date, end_date, kuota, status, description, loyal_only checkbox
- Validasi display errors from Form Request
- Submit button text dinamis (Simpan/Update)
#### `resources/views/admin-rental/promos/create.blade.php`
- Wrapper page untuk create
- Include form.blade.php dengan action POST ke store
#### `resources/views/admin-rental/promos/edit.blade.php`
- Wrapper page untuk edit
- Include form.blade.php dengan action PUT ke update
---
### Admin CRM Customer (2 files)
#### `resources/views/admin-rental/customers/index.blade.php`
- Table dengan kolom: nama, email, HP, total booking, booking selesai, total transaksi, rata-rata rating, booking terakhir, status loyalitas, aksi
- Search by name & email
- Filter: Semua Customer, Loyal (2+ booking), Non-Loyal (<2 booking)
- Show badge 🏆 untuk loyal customer
- Pagination
- Detail button link ke show page
#### `resources/views/admin-rental/customers/show.blade.php`
- 4-col stat cards: total booking, completed booking, total transaction, average rating
- 2-column layout:
- **Left**: Data pribadi, riwayat booking (10 terbaru paginated), review yang diberikan
- **Right**: Informasi loyalitas (status + syarat), kendaraan yang pernah dirental, rekomendasi target promo
- Booking history: code (link to detail), vehicle, tanggal, total, status
- Reviews: vehicle, booking code, rating (⭐), review text, waktu
- Vehicles: nama, brand, category (link to edit)
---
### Booking Integration (2 files updated)
#### `resources/views/booking/promo-voucher.blade.php`
- Input untuk promo_code (text field, uppercase)
- List available promos dari controller dengan:
- Kode promo, judul, discount label
- Tombol "Pakai" jika promo dapat digunakan
- Pesan error jika tidak dapat digunakan (quota, expiry, etc)
- Kuota status (X dari Y tersisa)
- Auto-fill promo code saat click "Pakai" button
- Info text: "Diskon dihitung 100% di backend"
#### `resources/views/booking/ringkasan-biaya.blade.php`
- Updated calculation untuk menampilkan estimated discount
- Jika promo_code ada di request, estimasi diskon ditampilkan
- Diskon row highlight green jika ada
- Info text: "Nilai estimasi, final dihitung di backend"
---
### Sidebar Update
**Location**: `resources/views/components/admin-rental-sidebar.blade.php`
- Updated link "Data Customer" → `route('admin-rental.customers.index')`
- Updated link "Promo" → `route('admin-rental.promos.index')`
- Active state check untuk kedua menu
---
## 9. Booking Flow Integration
### Customer Booking Create Page
**Flow**:
1. BookingController@create:
- Get `$availablePromos` via `$this->promoService->getVisiblePromosForBooking()`
- Pass ke view untuk render daftar promo yang bisa dipakai
2. User input promo code → ringkasan-biaya.blade.php kalkulasi estimasi diskon
3. Form submit → BookingController@store:
- Validasi promo_code via StoreBookingRequest
- Call `$this->promoService->resolvePromoForBooking()` dalam DB transaction dengan lockForUpdate
- If valid → set `booking.promo_id` dan hitung `booking.discount_amount`
- Increment `promo.used_count`
- If ValidationException → return back with errors
---
## 10. Key Implementation Details
### Promo Code Normalization
- Automatic uppercase saat save
- Display uppercase di UI
### Validation di Backend
- StorePromoRequest: validasi format & unique per rental
- resolvePromoForBooking: validasi saat booking dengan lock untuk prevent race condition
- PromoService: bisnis logic terpusat & reusable
### Loyalitas Filter Approach
```php
// Robust subquery untuk reliable distinct count
$loyalCustomerIdSubQuery = Booking::query()
->select('customer_id')
->where('rental_company_id', $rentalCompanyId)
->where('booking_status', Booking::BOOKING_COMPLETED)
->groupBy('customer_id')
->havingRaw('COUNT(*) >= ?', [2]);
// Gunakan whereIn/whereNotIn (lebih reliable dari having)
$customers->whereIn('id', $loyalCustomerIdSubQuery) // loyal
$customers->whereNotIn('id', $loyalCustomerIdSubQuery) // non-loyal
```
### Transaction Locking
```php
DB::transaction(function () {
$promo = Promo::where('id', $promoId)->lockForUpdate()->first();
// Validate promo (status, dates, quota)
$promo->increment('used_count');
// Save booking dengan promo_id
});
```
---
## 11. Testing Checklist
### Promo Admin Module
- [ ] Create promo (valid & invalid input)
- [ ] Edit promo (cannot change promo_code)
- [ ] Delete promo
- [ ] Toggle status active/inactive
- [ ] Search by title & promo code
- [ ] Filter by status
- [ ] Pagination works
- [ ] Only see own rental's promos
- [ ] Promo code normalized uppercase
### CRM Customer Module
- [ ] List customers dengan aggregates correct
- [ ] Filter loyal (2+ completed bookings)
- [ ] Filter non-loyal
- [ ] Search by name & email
- [ ] View customer detail
- [ ] Booking history paginated correctly
- [ ] Reviews displayed correct
- [ ] Vehicles list correct
- [ ] Loyalty status calculated correct
- [ ] Only see own rental's customers
### Booking Integration
- [ ] See available promos list di create page
- [ ] List shows correct promo details & estimated discount
- [ ] Click "Pakai" auto-fill promo code
- [ ] Invalid promo shows error reason
- [ ] Expired promo shows "tidak dapat digunakan"
- [ ] Loyal-only promo filtered correctly
- [ ] Quota checks work
- [ ] ringkasan-biaya shows estimated discount
- [ ] Backend recalculate final discount saat save
- [ ] promo.used_count increment saat booking saved
- [ ] Promo validation dengan lock prevent race condition
---
## 12. File Manifest
### Created Files (15)
1. `database/migrations/2026_04_21_000011_create_promos_table.php`
2. `database/migrations/2026_04_21_000012_add_promo_foreign_key_to_bookings_table.php`
3. `app/Models/Promo.php`
4. `app/Services/PromoService.php`
5. `app/Http/Requests/StorePromoRequest.php`
6. `app/Http/Requests/UpdatePromoRequest.php`
7. `app/Http/Controllers/AdminRental/PromoController.php`
8. `app/Http/Controllers/AdminRental/CustomerController.php`
9. `resources/views/admin-rental/promos/index.blade.php`
10. `resources/views/admin-rental/promos/form.blade.php`
11. `resources/views/admin-rental/promos/create.blade.php`
12. `resources/views/admin-rental/promos/edit.blade.php`
13. `resources/views/admin-rental/customers/index.blade.php`
14. `resources/views/admin-rental/customers/show.blade.php`
15. `resources/views/booking/promo-voucher.blade.php` (updated)
### Modified Files (8)
1. `app/Models/Booking.php` - Added promo() relation
2. `app/Models/RentalCompany.php` - Added promos() & bookings() relations
3. `app/Http/Requests/StoreBookingRequest.php` - Added promo_code validation
4. `app/Http/Controllers/Customer/BookingController.php` - Integrated PromoService
5. `routes/web.php` - Registered promo & customer routes
6. `resources/views/components/admin-rental-sidebar.blade.php` - Updated menu links
7. `resources/views/booking/ringkasan-biaya.blade.php` - Display estimated discount
8. `app/Models/User.php` - Removed invalid promos() relation (fixed)
---
## 13. Next Steps (Out of Scope for Tahap 8)
- [ ] Run migration di database real
- [ ] Integration testing booking promo flow end-to-end
- [ ] Super admin module untuk approve/manage all promos
- [ ] Email notification untuk customer dapat promo baru
- [ ] Advanced CRM: email campaign, promo targeting automation
- [ ] Analytics dashboard untuk promo performance
---
## 14. Notes
- Semua promo validation 100% di backend, frontend hanya estimasi
- Admin rental access control enforce di setiap controller action
- Loyalitas logic konsisten di promo filter & CRM display
- Service pattern keep business logic terpusat & testable
- Blade templates follow existing admin rental pattern untuk consistency

277
DOKUMENTASI_WEBSITE.md Normal file
View File

@ -0,0 +1,277 @@
# Dokumentasi Website WebRental untuk Pemula
Dokumen ini menjelaskan website WebRental dengan bahasa sederhana supaya mudah dipahami oleh orang yang baru pertama kali melihat sistem ini.
## 1. WebRental Itu Apa?
WebRental adalah website rental kendaraan. Lewat website ini, orang bisa:
- melihat daftar kendaraan yang tersedia
- membaca detail kendaraan
- booking kendaraan
- mengunggah bukti pembayaran
- melihat status booking
- memberi ulasan setelah selesai memakai kendaraan
Di sisi lain, admin rental dan super admin bisa mengelola data serta memantau aktivitas bisnis dari dashboard masing-masing.
## 2. Siapa Saja Penggunanya?
Website ini punya 3 jenis pengguna utama.
### Customer
Customer adalah pengguna yang menyewa kendaraan.
Yang bisa dilakukan customer:
- daftar akun dan login
- melihat katalog kendaraan
- melihat detail kendaraan
- melakukan booking
- mengunggah bukti pembayaran
- melihat status booking
- membaca riwayat booking
- memberi review setelah booking selesai
### Admin Rental
Admin rental adalah pengelola untuk satu perusahaan rental.
Yang bisa dilakukan admin rental:
- login ke area rental
- menambah, mengubah, dan menghapus kendaraan
- melihat booking yang masuk
- memverifikasi atau menolak pembayaran
- mengubah status booking menjadi ongoing atau completed
- mengelola promo
- melihat laporan rental miliknya sendiri
### Super Admin
Super admin adalah pengelola utama website.
Yang bisa dilakukan super admin:
- login ke area super admin
- melihat semua user
- memverifikasi rental
- melihat laporan seluruh platform
- melihat komisi platform
- melihat audit log atau jejak aktivitas sistem
- memantau semua rental
## 3. Halaman Penting di Website
### Halaman Home
Halaman home adalah halaman utama yang pertama kali dilihat pengunjung.
Di halaman ini biasanya ada:
- banner utama
- keunggulan layanan
- daftar kendaraan unggulan
- promo
- testimoni pelanggan
- form pencarian kendaraan
Tujuan halaman ini adalah membantu pengunjung cepat memahami layanan dan menemukan kendaraan yang cocok.
### Halaman Login dan Register
Halaman ini digunakan agar pengguna bisa masuk ke akun masing-masing.
Fungsinya:
- customer bisa login atau daftar akun baru
- admin rental dan super admin bisa login ke dashboard masing-masing
Setelah login, sistem akan mengarahkan user ke halaman yang sesuai dengan role-nya.
### Halaman Katalog Kendaraan
Halaman katalog menampilkan semua kendaraan yang bisa disewa.
Pengunjung bisa:
- melihat foto kendaraan
- membaca nama dan detail singkat
- membuka halaman detail kendaraan
### Halaman Detail Kendaraan
Halaman ini berisi informasi lengkap kendaraan.
Biasanya berisi:
- foto kendaraan
- merk dan tipe
- harga per hari
- kapasitas kursi
- transmisi
- bahan bakar
- deskripsi kendaraan
Halaman ini membantu customer memutuskan apakah kendaraan cocok untuk dibooking.
### Halaman Booking
Di halaman ini customer memilih kendaraan dan mengisi data booking.
Alurnya:
1. customer memilih kendaraan
2. customer memilih tanggal sewa
3. sistem mengecek ketersediaan kendaraan
4. booking disimpan
5. customer diarahkan ke pembayaran
### Halaman Pembayaran
Setelah booking dibuat, customer akan masuk ke halaman pembayaran.
Di halaman ini customer bisa:
- melihat detail booking
- memilih metode pembayaran
- mengunggah bukti transfer
Setelah upload bukti pembayaran, status akan berubah menjadi uploaded dan menunggu dicek admin rental.
### Halaman My Bookings
Halaman ini berisi daftar booking milik customer.
Customer bisa melihat:
- booking yang sedang berjalan
- booking yang sudah selesai
- status pembayaran
- detail transaksi
### Halaman Review
Jika booking sudah selesai, customer bisa memberi ulasan.
Review ini berguna untuk membantu customer lain melihat pengalaman pengguna sebelumnya.
### Halaman Dashboard Admin Rental
Dashboard ini adalah pusat kerja admin rental.
Di dalamnya admin rental bisa melihat ringkasan seperti:
- total kendaraan
- total booking
- total customer
- total pembayaran terverifikasi
- grafik booking bulanan
- akses cepat ke menu penting
### Halaman Dashboard Super Admin
Dashboard super admin dipakai untuk memantau seluruh sistem.
Biasanya menampilkan:
- total rental
- total user
- total booking
- total revenue
- total komisi
- grafik booking per bulan
- akses cepat ke laporan dan monitoring
### Halaman Laporan
Halaman laporan dipakai untuk membaca data bisnis secara lebih serius.
Laporan ini membantu pengguna memahami:
- berapa booking yang masuk
- pembayaran mana yang sudah verified
- kendaraan mana yang paling laku
- customer mana yang paling aktif
- berapa pendapatan bisnis
- berapa komisi platform
Laporan super admin melihat semua data platform.
Laporan admin rental hanya melihat data milik rental sendiri.
### Halaman Audit Log
Audit log adalah halaman catatan aktivitas sistem.
Halaman ini dipakai untuk melihat:
- siapa yang melakukan aksi
- aksi apa yang dilakukan
- kapan aksi dilakukan
- data apa yang berubah
Tujuannya untuk monitoring dan pengecekan jika ada masalah.
## 4. Alur Sederhana Cara Kerja Website
### Alur Customer
1. customer buka website
2. customer melihat katalog kendaraan
3. customer membuka detail kendaraan
4. customer booking kendaraan
5. customer melakukan pembayaran
6. admin rental mengecek pembayaran
7. jika valid, booking diproses
8. setelah selesai, customer bisa memberi review
### Alur Admin Rental
1. admin rental login
2. admin membuka dashboard
3. admin melihat booking masuk
4. admin mengecek pembayaran
5. admin mengubah status booking bila perlu
6. admin mengelola kendaraan dan promo
7. admin membuka laporan rental
### Alur Super Admin
1. super admin login
2. super admin membuka dashboard utama
3. super admin melihat daftar user dan rental
4. super admin membuka laporan platform
5. super admin memantau audit log
6. super admin memonitor kesehatan sistem secara umum
## 5. Penjelasan Status yang Sering Dipakai
### Status Booking
- waiting_payment: menunggu pembayaran
- waiting_verification: menunggu verifikasi pembayaran
- confirmed: booking sudah dikonfirmasi
- ongoing: kendaraan sedang dipakai
- completed: booking sudah selesai
- cancelled: booking dibatalkan
### Status Pembayaran
- unpaid: belum ada pembayaran
- uploaded: bukti pembayaran sudah diunggah
- verified: pembayaran sudah disetujui admin
- rejected: pembayaran ditolak admin
## 6. Kenapa Ada Role Berbeda?
Role berbeda dibuat supaya setiap orang hanya melihat menu dan data yang sesuai tugasnya.
Contohnya:
- customer tidak bisa masuk ke halaman admin
- admin rental tidak bisa melihat data rental lain
- super admin bisa memantau semua data di platform
Ini membuat sistem lebih rapi, aman, dan mudah dikelola.
## 7. Ringkasan Singkat
Kalau dijelaskan dengan sederhana, WebRental adalah website untuk menyewa kendaraan secara online.
Customer memakai website untuk booking dan membayar.
Admin rental memakai website untuk mengelola kendaraan dan memproses booking.
Super admin memakai website untuk mengawasi seluruh sistem.
## 8. Cocok Dibaca Siapa?
Dokumen ini cocok untuk:
- pemula yang baru pertama kali melihat project ini
- developer baru yang ingin memahami alur website
- pemilik project yang ingin tahu fungsi tiap bagian website

98
README.md Normal file
View File

@ -0,0 +1,98 @@
# WebRental
Platform rental kendaraan berbasis Laravel untuk multi-rental dengan role `customer`, `admin_rental`, dan `super_admin`.
## Ringkasan Fitur
- Auth multi-role: customer, admin rental, super admin
- CRUD kendaraan admin rental
- Katalog dan detail kendaraan customer
- Booking kendaraan
- Pembayaran dan upload bukti transfer
- Verifikasi pembayaran oleh admin rental
- Riwayat booking customer
- Status booking operasional admin rental
- Review customer
- Promo dan CRM admin rental
- Dashboard super admin
- Laporan platform dan rental
- Notification, activity log, policy authorization, dan hardening dasar
## Role
- `customer`: melihat katalog, booking, bayar, review, dan riwayat booking sendiri
- `admin_rental`: mengelola kendaraan, booking, pembayaran, promo, customer, dan laporan rental sendiri
- `super_admin`: mengelola seluruh platform, verifikasi rental, user, komisi, laporan, dan audit
## Akun Demo
Password semua akun demo: `password123`
- `superadmin@example.com` - Super Admin
- `adminrental1@example.com` - Admin Rental 1
- `adminrental2@example.com` - Admin Rental 2
- `customer1@example.com` - Customer demo utama
- `customer2@example.com` - Customer demo tambahan
- `customer3@example.com` - Customer demo tambahan
- `customer4@example.com` - Customer demo tambahan
- `customer5@example.com` - Customer demo tambahan
## Setup Lokal
1. Install dependency.
```bash
composer install
npm install
```
2. Siapkan environment.
```bash
cp .env.example .env
php artisan key:generate
```
3. Konfigurasi database di `.env`.
4. Jalankan migration dan seeder demo.
```bash
php artisan migrate
php artisan db:seed
```
5. Siapkan storage.
```bash
php artisan storage:link
```
6. Jalankan aplikasi.
```bash
php artisan serve
npm run dev
```
## Command Penting
```bash
php artisan migrate
php artisan db:seed
php artisan route:list
php artisan test
php artisan view:clear
php artisan config:clear
php artisan cache:clear
```
## Dokumentasi Tambahan
- [Checklist Testing](TESTING_CHECKLIST.md)
- [System Flow](SYSTEM_FLOW.md)
- [Deployment Guide](DEPLOYMENT.md)
## Catatan Finalisasi
Tahap 12 memfokuskan project pada kesiapan demo, pengujian, dan deployment. Data demo sudah disiapkan agar laporan, booking, pembayaran, dan review bisa langsung dipresentasikan.

105
SYSTEM_FLOW.md Normal file
View File

@ -0,0 +1,105 @@
# System Flow
Dokumentasi ringkas alur sistem WebRental.
## Struktur Role
### Customer
- Registrasi dan login
- Melihat katalog kendaraan
- Melihat detail kendaraan
- Booking kendaraan
- Upload bukti pembayaran
- Melihat status booking dan riwayat
- Memberikan review setelah booking selesai
- Menggunakan promo yang valid
### Admin Rental
- Login ke area rental
- Mengelola kendaraan
- Mengelola booking masuk
- Verifikasi atau menolak pembayaran
- Mengubah status booking menjadi ongoing atau completed
- Mengelola promo dan CRM customer
- Melihat laporan rental sendiri
### Super Admin
- Login ke area super admin
- Verifikasi rental
- Melihat semua user
- Melihat laporan platform
- Melihat komisi platform
- Melihat aktivitas sistem
- Monitoring seluruh rental
## Alur Utama Sistem
### 1. Flow Booking
1. Customer membuka katalog.
2. Customer membuka detail kendaraan.
3. Customer memilih tanggal booking.
4. Sistem mengecek ketersediaan dan bentrok tanggal.
5. Customer mengisi data booking.
6. Booking disimpan dengan status awal yang sesuai.
7. Customer diarahkan ke pembayaran.
### 2. Flow Pembayaran
1. Customer membuka halaman pembayaran booking.
2. Customer memilih metode pembayaran.
3. Customer upload bukti pembayaran.
4. Status pembayaran berubah menjadi `uploaded`.
5. Admin rental mengecek bukti.
6. Admin rental verifikasi atau menolak pembayaran.
### 3. Flow Verifikasi Rental
1. Admin rental melihat daftar booking dan pembayaran masuk.
2. Admin rental memeriksa bukti pembayaran.
3. Jika valid, status berubah menjadi `verified`.
4. Jika tidak valid, status berubah menjadi `rejected`.
5. Customer menerima notifikasi status pembayaran.
### 4. Flow Operasional Booking
1. Setelah pembayaran verified, booking bisa diproses.
2. Admin rental mengubah status menjadi `ongoing`.
3. Setelah kendaraan kembali, admin rental mengubah status menjadi `completed`.
4. Customer bisa memberikan review setelah completed.
### 5. Flow Review
1. Booking selesai.
2. Customer membuka detail booking.
3. Customer mengisi rating dan ulasan.
4. Review disimpan dan ditampilkan pada data rental/kendaraan.
### 6. Flow Promo + CRM
1. Admin rental membuat promo.
2. Promo bisa bersifat umum atau `loyal_only`.
3. Customer loyal mendapatkan akses promo tertentu.
4. Promo diterapkan saat booking jika valid.
5. Data CRM dipakai untuk monitoring customer aktif dan loyal.
### 7. Flow Laporan
1. Super admin membuka laporan platform.
2. Admin rental membuka laporan rental sendiri.
3. Laporan memakai data booking dan payment nyata.
4. Filter tanggal dan status dipakai untuk analisis.
### 8. Flow Komisi
1. Sistem mengambil transaksi dengan `payment_status = verified`.
2. Komisi dihitung dari persentase tetap 10%.
3. Total revenue dan total komisi ditampilkan di laporan.
4. Super admin dapat melihat komisi per transaksi dan per rental.
## Cek Akhir Konsistensi
Sebelum project dianggap selesai, pastikan:
- Route name konsisten
- Role middleware bekerja
- Policy tidak bentrok
- Flash message jelas
- Redirect setelah aksi sesuai role
- Filter laporan tetap terbawa saat pagination
- Upload file tersimpan di path yang benar
- Status booking dan payment sinkron
- Dashboard quick links berfungsi
- Empty state tampil saat data kosong

1243
TAHAP_10_DOKUMENTASI.md Normal file

File diff suppressed because it is too large Load Diff

606
TAHAP_11_DOKUMENTASI.md Normal file
View File

@ -0,0 +1,606 @@
# TAHAP 11 - MODUL LAPORAN (REPORTING MODULE)
**Versi**: 1.0
**Status**: ✅ Selesai
**Tanggal**: April 2026
---
## RINGKASAN TAHAP 11
**TAHAP 11** fokus pada pengembangan **Modul Laporan** yang komprehensif untuk memberikan visibilitas dan monitoring terhadap operasional platform dan masing-masing rental. Laporan dirancang untuk mendukung decision making, tracking KPI, dan analisis bisnis tanpa menambah modul bisnis baru.
### Tujuan Tahap 11
1. **Super Admin** dapat melihat laporan platform-wide untuk monitoring, verifikasi, dan pelaporan bisnis
2. **Admin Rental** dapat melihat laporan milik rental mereka sendiri untuk monitoring operasional dan revenue
3. Semua laporan berbasis **data nyata** dengan query yang efisien
4. Filter date range dan filter tambahan (rental, booking status, payment status) sesuai konteks
5. Tampilan laporan sederhana, clean, dan fokus pada data readability
---
## JENIS LAPORAN
### A. UNTUK SUPER ADMIN (7 Laporan)
#### 1. **Dashboard Laporan (Index)**
- **Route**: `GET /super-admin/reports`
- **Nama Route**: `super-admin.reports.index`
- **Akses**: Super Admin
- **Deskripsi**: Ringkasan metrik lintas platform
**Menampilkan**:
- Total Booking (semua rental)
- Total Payment Verified
- Total Kendaraan
- Total Revenue (verified)
- Total Komisi (10% dari revenue)
- Total Customer Aktif
- Quick links ke laporan detail
**Filter**: Date range (start_date, end_date)
---
#### 2. **Laporan Booking per Rental**
- **Route**: `GET /super-admin/reports/bookings`
- **Nama Route**: `super-admin.reports.bookings`
- **Deskripsi**: Data booking dari semua rental di platform
**Data yang ditampilkan**:
- Booking code, Rental, Customer, Kendaraan
- Pickup date, Return date, Duration
- Total Amount, Booking Status, Payment Status
- Created date
**Summary Statistik**:
- Total Booking
- Total Completed
- Total Cancelled
- Total Ongoing
**Filter Tersedia**:
- `start_date` (nullable|date)
- `end_date` (nullable|date|after_or_equal:start_date)
- `rental_id` (nullable|exists:rental_companies,id)
- `booking_status` (nullable|in:waiting_payment,waiting_verification,confirmed,ongoing,completed,cancelled)
**Pagination**: 15 items per page
---
#### 3. **Laporan Pembayaran Platform**
- **Route**: `GET /super-admin/reports/payments`
- **Nama Route**: `super-admin.reports.payments`
- **Deskripsi**: Data pembayaran dari semua rental di platform
**Data yang ditampilkan**:
- Booking code, Rental, Customer
- Payment method, Amount
- Payment Status, Verified Date, Verified By
- Rejection Note (jika rejected)
**Summary Statistik**:
- Total Payment Verified
- Total Payment Uploaded
- Total Payment Rejected
- Total Nominal Verified (jumlah uang yang terverifikasi)
**Filter Tersedia**:
- `start_date`
- `end_date`
- `rental_id`
- `payment_status` (nullable|in:unpaid,uploaded,verified,rejected)
**Pagination**: 15 items per page
---
#### 4. **Laporan Kendaraan Terlaris Platform**
- **Route**: `GET /super-admin/reports/top-vehicles`
- **Nama Route**: `super-admin.reports.top-vehicles`
- **Deskripsi**: Ranking kendaraan berdasarkan jumlah booking verified
**Data yang ditampilkan**:
- Rank (#)
- Nama Kendaraan, Brand, Category
- Rental (milik siapa)
- Total Booking (verified)
- Total Revenue (dari booking verified)
**Logika Perhitungan**:
```
Ranking = COUNT(bookings) WHERE vehicle_id = X AND payment_status = 'verified'
Revenue = SUM(total_amount) WHERE vehicle_id = X AND payment_status = 'verified'
```
**Filter Tersedia**:
- `start_date`
- `end_date`
- `limit` (default: 20, max: 100)
---
#### 5. **Laporan Customer Aktif Platform**
- **Route**: `GET /super-admin/reports/active-customers`
- **Nama Route**: `super-admin.reports.active-customers`
- **Deskripsi**: Ranking customer berdasarkan jumlah booking completed
**Data yang ditampilkan**:
- Rank (#)
- Nama Customer, Email, Phone
- Total Booking, Completed Booking
- Total Transaksi (verified)
- Last Booking Date
- Status Loyal (🏅 jika >= 3 completed bookings)
**Logika Perhitungan**:
```
Total Booking = COUNT(bookings) WHERE customer_id = X
Completed = COUNT(bookings) WHERE customer_id = X AND booking_status = 'completed'
Total Transaksi = SUM(total_amount) WHERE customer_id = X AND payment_status = 'verified'
Loyal = IF(completed >= 3) THEN 'Loyal' ELSE 'Regular'
```
**Filter Tersedia**:
- `start_date`
- `end_date`
- `limit` (default: 20, max: 100)
---
#### 6. **Laporan Pendapatan Rental**
- **Route**: `GET /super-admin/reports/revenue`
- **Nama Route**: `super-admin.reports.revenue`
- **Deskripsi**: Revenue breakdown per rental dan kalkulasi komisi platform
**Data yang ditampilkan**:
- Nama Rental, Admin Rental
- Booking Verified Count
- Pendapatan Gross (total dari verified payment)
- Komisi (10%)
- Pendapatan Net (Gross - Komisi)
**Logika Perhitungan**:
```
Booking Verified = COUNT(bookings) WHERE rental_id = X AND payment_status = 'verified'
Gross Revenue = SUM(total_amount) WHERE rental_id = X AND payment_status = 'verified'
Commission = Gross Revenue × 10%
Net Revenue = Gross Revenue - Commission
```
**Summary di halaman**:
- Total Gross Revenue (semua rental)
- Total Commission (semua rental)
- Total Net Revenue (semua rental)
**Filter Tersedia**:
- `start_date`
- `end_date`
---
#### 7. **Laporan Komisi Platform**
- **Route**: `GET /super-admin/reports/commissions`
- **Nama Route**: `super-admin.reports.commissions`
- **Deskripsi**: Detail komisi platform dari setiap transaksi verified
**Data yang ditampilkan**:
- Booking Code, Rental, Customer
- Amount (booking total)
- Komisi (10% dari amount)
- Payment Status, Booking Status
- Tanggal
**Summary Statistik**:
- Total Transactions (verified)
- Total Gross Revenue
- Total Commission
- Avg Commission per Booking
**Logika Perhitungan**:
```
Total Transactions = COUNT(bookings) WHERE payment_status = 'verified'
Gross Revenue = SUM(total_amount) WHERE payment_status = 'verified'
Commission per Booking = amount × 10%
Total Commission = SUM(commission_per_booking)
Avg Commission = Total Commission / Total Transactions
```
**Filter Tersedia**:
- `start_date`
- `end_date`
- `rental_id`
**Pagination**: 20 items per page
---
### B. UNTUK ADMIN RENTAL (6 Laporan)
#### 1. **Dashboard Laporan Rental (Index)**
- **Route**: `GET /admin-rental/reports`
- **Nama Route**: `admin-rental.reports.index`
- **Akses**: Admin Rental
- **Deskripsi**: Ringkasan metrik untuk rental mereka sendiri
**Menampilkan**:
- Total Booking (milik rental)
- Total Payment Verified (milik rental)
- Total Kendaraan (milik rental)
- Total Revenue (verified, milik rental)
- Total Komisi (10%, dari revenue rental)
- Total Customer Aktif (yang booking ke rental ini)
- Quick links ke laporan detail
**Filter**: Date range (start_date, end_date)
---
#### 2. **Laporan Booking Rental**
- **Route**: `GET /admin-rental/reports/bookings`
- **Nama Route**: `admin-rental.reports.bookings`
- **Deskripsi**: Data booking untuk rental ini saja
**Data yang ditampilkan**: Sama dengan super admin, tapi hanya untuk rental yang login
**Summary Statistik**: Sama dengan super admin
**Filter Tersedia**:
- `start_date`
- `end_date`
- `booking_status` (TIDAK ada rental_id karena auto-filtered ke rental mereka)
**Pagination**: 15 items per page
---
#### 3. **Laporan Pembayaran Rental**
- **Route**: `GET /admin-rental/reports/payments`
- **Nama Route**: `admin-rental.reports.payments`
- **Deskripsi**: Data pembayaran untuk rental ini saja
**Data yang ditampilkan**: Sama dengan super admin, tapi hanya untuk rental yang login
**Summary Statistik**: Sama dengan super admin
**Filter Tersedia**:
- `start_date`
- `end_date`
- `payment_status`
**Pagination**: 15 items per page
---
#### 4. **Laporan Kendaraan Terlaris Rental**
- **Route**: `GET /admin-rental/reports/top-vehicles`
- **Nama Route**: `admin-rental.reports.top-vehicles`
- **Deskripsi**: Ranking kendaraan milik rental mereka berdasarkan booking verified
**Data yang ditampilkan**: Sama dengan super admin, tapi hanya kendaraan milik rental
**Filter Tersedia**:
- `start_date`
- `end_date`
- `limit`
---
#### 5. **Laporan Customer Aktif Rental**
- **Route**: `GET /admin-rental/reports/active-customers`
- **Nama Route**: `admin-rental.reports.active-customers`
- **Deskripsi**: Ranking customer yang pernah booking ke rental mereka
**Data yang ditampilkan**: Sama dengan super admin, tapi hanya customer yang booking ke rental ini
**Filter Tersedia**:
- `start_date`
- `end_date`
- `limit`
---
#### 6. **Laporan Pendapatan Rental**
- **Route**: `GET /admin-rental/reports/revenue`
- **Nama Route**: `admin-rental.reports.revenue`
- **Deskripsi**: Detail revenue untuk rental mereka sendiri
**Data yang ditampilkan**:
- Informasi rental (nama, admin)
- Booking Verified Count
- Pendapatan Gross
- Komisi (10%)
- Pendapatan Net
- Detail breakdown dengan penjelasan lengkap
**Logika Perhitungan**: Sama dengan super admin tapi untuk 1 rental saja
**Filter Tersedia**:
- `start_date`
- `end_date`
---
## STRUKTUR FILE
### Controllers
```
app/Http/Controllers/
├── SuperAdmin/
│ └── ReportController.php (7 methods: index, bookings, payments, topVehicles, activeCustomers, revenue, commissions)
└── AdminRental/
└── ReportController.php (6 methods: index, bookings, payments, topVehicles, activeCustomers, revenue)
```
### Views
```
resources/views/
├── super-admin/reports/
│ ├── index.blade.php
│ ├── bookings.blade.php
│ ├── payments.blade.php
│ ├── top-vehicles.blade.php
│ ├── active-customers.blade.php
│ ├── revenue.blade.php
│ └── commissions.blade.php
└── admin-rental/reports/
├── index.blade.php
├── bookings.blade.php
├── payments.blade.php
├── top-vehicles.blade.php
├── active-customers.blade.php
└── revenue.blade.php
```
### Routes (dalam routes/web.php)
```php
// Super Admin Reports (7 routes)
Route::get('/reports', [SuperAdminReportController::class, 'index'])->name('reports.index');
Route::get('/reports/bookings', [SuperAdminReportController::class, 'bookings'])->name('reports.bookings');
Route::get('/reports/payments', [SuperAdminReportController::class, 'payments'])->name('reports.payments');
Route::get('/reports/top-vehicles', [SuperAdminReportController::class, 'topVehicles'])->name('reports.top-vehicles');
Route::get('/reports/active-customers', [SuperAdminReportController::class, 'activeCustomers'])->name('reports.active-customers');
Route::get('/reports/revenue', [SuperAdminReportController::class, 'revenue'])->name('reports.revenue');
Route::get('/reports/commissions', [SuperAdminReportController::class, 'commissions'])->name('reports.commissions');
// Admin Rental Reports (6 routes)
Route::get('/reports', [AdminRentalReportController::class, 'index'])->name('reports.index');
Route::get('/reports/bookings', [AdminRentalReportController::class, 'bookings'])->name('reports.bookings');
Route::get('/reports/payments', [AdminRentalReportController::class, 'payments'])->name('reports.payments');
Route::get('/reports/top-vehicles', [AdminRentalReportController::class, 'topVehicles'])->name('reports.top-vehicles');
Route::get('/reports/active-customers', [AdminRentalReportController::class, 'activeCustomers'])->name('reports.active-customers');
Route::get('/reports/revenue', [AdminRentalReportController::class, 'revenue'])->name('reports.revenue');
```
---
## ATURAN BISNIS UNTUK PERHITUNGAN
### 1. Revenue & Commission
```
Gross Revenue = SUM(Booking.total_amount) WHERE payment_status = 'verified'
Commission = Gross Revenue × 10%
Net Revenue = Gross Revenue - Commission
```
- **Hanya transaksi verified yang dihitung**
- **Komisi tetap 10%** (tidak berubah)
- **Tidak ada diskon atau bonus di level laporan**
### 2. Booking Status untuk Statistik
```
Valid Status untuk statistik: 'completed', 'ongoing', 'confirmed'
Calculation: COUNT(bookings) WHERE booking_status IN (...)
```
### 3. Payment Status untuk Perhitungan Uang
```
Hanya 'verified' yang dihitung untuk revenue/komisi
'uploaded' = belum verified
'rejected' = tidak dihitung
'unpaid' = tidak ada pembayaran
```
### 4. Customer Aktif & Loyal
```
Total Booking = COUNT(bookings) WHERE customer_id = X
Completed = COUNT(bookings) WHERE customer_id = X AND booking_status = 'completed'
Loyal Status = IF(completed >= 3) THEN 'Loyal' ELSE 'Regular'
```
### 5. Top Vehicles Ranking
```
Ranking = ORDER BY COUNT(bookings) DESC WHERE payment_status = 'verified'
Revenue per Vehicle = SUM(total_amount) WHERE payment_status = 'verified'
```
---
## FITUR KEAMANAN & VALIDASI
### Authorization
1. **Super Admin** - Full access ke semua laporan platform-wide
2. **Admin Rental** - Access HANYA laporan milik rental mereka sendiri
- Automatic filter: `WHERE rental_company_id = Auth::user()->rentalCompany->id`
- Tidak bisa akses laporan rental lain
### Validation
```php
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'rental_id' => ['nullable', 'exists:rental_companies,id'], // Super Admin only
'booking_status' => ['nullable', 'in:waiting_payment,...'],
'payment_status' => ['nullable', 'in:unpaid,uploaded,verified,rejected'],
'limit' => ['nullable', 'integer', 'min:5', 'max:100'],
]);
```
### Query Efficiency
1. **Eager Loading**: `with(['customer', 'vehicle.rentalCompany', 'payment'])`
2. **Aggregate Queries**: `withCount()`, `withSum()`, `withMax()` untuk statistik
3. **No N+1**: Semua relasi di-load dengan 1 query
4. **Pagination**: Default 15-20 items per page untuk performa
---
## FITUR FILTER
### Filter Universal (di semua laporan)
- **start_date**: Dari tanggal berapa (Booking.created_at >=)
- **end_date**: Sampai tanggal berapa (Booking.created_at <=)
### Filter Tambahan Per Laporan
| Laporan | Filter Tambahan | Keterangan |
|---------|-----------------|-----------|
| Booking | rental_id, booking_status | Super Admin bisa filter per rental |
| Payment | rental_id, payment_status | Super Admin bisa filter per rental |
| Top Vehicles | limit | Default 20, max 100 |
| Active Customers | limit | Default 20, max 100 |
| Revenue | None | Auto-per rental |
| Commissions | rental_id | Filter transaksi komisi per rental |
### Filter Behavior
- **Semua filter nullable** - Tidak ada yang required
- **Query string preserved** - Pagination mempertahankan filter
- **Reset button** - Kembalikan ke filter default
---
## CARA MENGAKSES LAPORAN
### Super Admin
1. Login sebagai Super Admin
2. Buka menu "Laporan" di sidebar (atau `/super-admin/reports`)
3. Pilih laporan yang ingin dilihat dari dashboard
4. Gunakan filter untuk narrow down data
5. Lihat statistik ringkas di atas tabel
6. Scroll untuk melihat detail data
### Admin Rental
1. Login sebagai Admin Rental
2. Buka menu "Laporan" di sidebar (atau `/admin-rental/reports`)
3. Pilih laporan dari dashboard
4. Filter sesuai kebutuhan
5. Data otomatis terbatas ke rental mereka sendiri
---
## TESTING LAPORAN
### Data Test yang Direkomendasikan
1. **Booking dengan payment_status = verified**: Untuk revenue calculation
2. **Booking dengan status completed**: Untuk active customer calculation
3. **Multiple rental**: Untuk test super admin vs admin rental filtering
4. **Multiple customers**: Untuk test customer active ranking
5. **Date range variation**: Untuk test filter tanggal
### Test Cases Minimal
```
✓ Super Admin dapat melihat semua laporan
✓ Admin Rental hanya melihat laporan milik rental-nya
✓ Filter date range bekerja sesuai
✓ Filter rental_id bekerja (super admin)
✓ Statistik ringkas terhitung akurat
✓ Pagination working
✓ Revenue & Commission calculated correctly (10%)
✓ Customer Loyalty status correct (>= 3)
```
---
## PERTIMBANGAN PERFORMA
### Query Optimization
1. **withCount()** vs `COUNT()` subquery - Lebih efisien
2. **withSum()** vs `SUM()` subquery - Lebih readable
3. **with()** untuk eager loading - Hindari N+1
4. **Index recommendation**:
```sql
CREATE INDEX idx_bookings_rental_payment ON bookings(rental_company_id, payment_status, created_at);
CREATE INDEX idx_bookings_customer_status ON bookings(customer_id, booking_status, created_at);
CREATE INDEX idx_bookings_vehicle_payment ON bookings(vehicle_id, payment_status, created_at);
```
### Caching (Future Enhancement)
- Cache revenue summary untuk 1 jam
- Cache top vehicles/customers untuk 6 jam
- Invalidate cache saat ada transaksi baru
---
## LANGKAH BERIKUTNYA (TAHAP 12+)
### Tahap 12: Export & Format
- [ ] Export laporan ke PDF
- [ ] Export laporan ke Excel (.xlsx)
- [ ] Email laporan periodic (daily/weekly/monthly)
- [ ] Print-friendly version
### Tahap 13: Advanced Analytics
- [ ] Chart & visualization (Chart.js)
- [ ] Trend analysis (month-over-month growth)
- [ ] Predictive analytics (forecast revenue)
- [ ] Comparison tools (rental A vs rental B)
### Tahap 14: Performance & Scalability
- [ ] Database indexing optimization
- [ ] Query caching strategy
- [ ] Materialized views untuk laporan heavy
- [ ] Background jobs untuk laporan besar
- [ ] Report scheduling & automation
### Tahap 15: User-Defined Reports
- [ ] Custom report builder (drag-drop columns)
- [ ] Save custom report templates
- [ ] Shared reports & dashboards
- [ ] Report access control
---
## NOTES & CATATAN PENTING
1. **Komisi Platform = 10%** - Bisa menjadi configurable di config/app.php jika perlu fleksibilitas
2. **Payment Status** - Hanya 'verified' yang masuk perhitungan revenue untuk akurasi keuangan
3. **Date Filter** - Menggunakan `created_at` booking, bukan `verified_at` atau `completed_at`
4. **Admin Rental Filter** - Automatic via `Auth::user()->rentalCompany->id`, bukan dari request
5. **Pagination State** - `withQueryString()` memastikan filter persist saat pindah halaman
6. **Empty States** - Semua laporan punya pesan "Belum ada data..." saat kosong
7. **Number Formatting** - Rupiah dengan `number_format($value, 0, ',', '.')`
8. **DateTime Display** - Format `d M Y` untuk tanggal (01 Jan 2024)
---
## CHECKLIST DEPLOYMENT
- [x] Controllers created & tested
- [x] Routes configured
- [x] Views created with proper layout
- [x] Filters validated
- [x] Authorization working (super admin vs admin rental)
- [x] Date range filtering working
- [x] Statistics calculation correct
- [x] Pagination implemented
- [x] Empty state messages
- [x] Query optimization (eager loading, aggregates)
- [x] Number formatting for currency
- [x] Quick links in dashboard
- [ ] Testing with real data
- [ ] Performance testing on large datasets
- [ ] User acceptance testing
---
## KESIMPULAN
**TAHAP 11** berhasil mengimplementasikan modul laporan komprehensif yang:
- ✅ Memberikan visibility platform-wide untuk super admin
- ✅ Memberikan visibility rental-specific untuk admin rental
- ✅ Semua laporan berbasis data real-time
- ✅ Query efficient dengan aggregate functions
- ✅ Proper authorization & data filtering
- ✅ Clean, simple, readable UI
- ✅ Siap untuk monitoring, analysis, dan decision making
Sistem sekarang cukup matang untuk production use dengan monitoring capability yang solid untuk semua stakeholder.

737
TAHAP_9_DOKUMENTASI.md Normal file
View File

@ -0,0 +1,737 @@
# Tahap 9: Fondasi Infrastruktur Sistem Rental
**Status**: ✅ COMPLETED
**Tanggal**: Fase 1 - Audit & Discovery
**Objective**: Membangun fondasi teknis untuk sistem yang stabil, aman, dan scalable
---
## 📋 Daftar Isi
1. [Overview](#overview)
2. [Deliverables](#deliverables)
3. [Models & Database](#models--database)
4. [Services Layer](#services-layer)
5. [Controllers Refactoring](#controllers-refactoring)
6. [Implementation Details](#implementation-details)
7. [Technical Architecture](#technical-architecture)
---
## Overview
Tahap 9 adalah fase foundational yang membangun infrastruktur inti untuk mendukung Tahap 10 (hardening). Fokus utama:
- ✅ Membuat service layer untuk business logic
- ✅ Implementing policies untuk authorization
- ✅ Mengintegrasikan file upload dengan validation
- ✅ Slug management system yang konsisten
- ✅ Migration & model standardization
### Scope Tahap 9
**TIDAK menambah fitur bisnis besar**, hanya:
- Infrastructure improvements
- Code organization & patterns
- Security foundation
- Data integrity
---
## Deliverables
### 1. Service Layer (4 Services)
#### PromoService (Existing)
```php
// Location: app/Services/PromoService.php
// Purpose: Promo business logic
const LOYAL_COMPLETED_THRESHOLD = 3;
public function isUserLoyal($userId, $rentalCompanyId): bool
public function getApplicablePromos($bookingData): Collection
```
#### SlugService (New)
```php
// Location: app/Services/SlugService.php
// Purpose: Centralized slug generation dengan uniqueness guarantee
public function generateUnique(
string $modelClass,
string $column,
string $source,
?int $ignoreId = null
): string
```
**Fitur:**
- Automatic slug generation dari source text
- Collision detection & appending (-1, -2, etc)
- Support untuk model yang berbeda
- Case-insensitive duplicate checking
#### FileUploadService (New)
```php
// Location: app/Services/FileUploadService.php
// Purpose: Secure file upload dengan naming & organization
public function storePublic(
UploadedFile $file,
string $directory
): string // returns "vehicles/main/2026/04/uuid.jpg"
public function deletePublic(string $path): bool
```
**Fitur:**
- UUID-based filename (prevents collisions)
- Date-based subdirectories (Y/m format)
- Safe deletion with error handling
- Returns relative path untuk storage
#### NotificationService (New - Tahap 10 prep)
```php
// Location: app/Services/NotificationService.php
// Purpose: Database notification management
public function notifyUser(
int $userId,
string $title,
string $message,
string $type,
?string $url = null,
?string $referenceType = null,
?int $referenceId = null
): UserNotification
```
#### ActivityLogService (New - Tahap 10 prep)
```php
// Location: app/Services/ActivityLogService.php
// Purpose: Immutable audit logging
public function log(
string $action,
string $description,
string $targetType,
int $targetId,
?array $meta = null,
?int $userId = null
): ActivityLog
```
---
### 2. Models & Database Migrations
#### Models Created/Updated
**UserNotification**
```php
// Table: user_notifications
// Columns:
// - id: bigint
// - user_id: bigint (FK users)
// - title: string
// - message: text
// - type: enum (info, success, warning, error)
// - reference_type: string (nullable) - booking, payment, etc
// - reference_id: bigint (nullable)
// - url: string (nullable)
// - read_at: timestamp (nullable)
// - created_at, updated_at
// Key Methods:
public function markAsRead(): self
public function isUnread(): bool
// Relationships:
public function user(): BelongsTo
```
**ActivityLog**
```php
// Table: activity_logs
// Columns:
// - id: bigint
// - user_id: bigint (FK users, nullable for system actions)
// - action: string (e.g., 'vehicle.created')
// - target_type: string (vehicle, booking, payment, etc)
// - target_id: bigint
// - description: text
// - meta: json (contextual data)
// - created_at (no updated_at - append-only)
// Indexes:
// - user_id
// - action
// - created_at
// Relationships:
public function user(): BelongsTo
```
**RentalCompany (Enhanced)**
- Booted with automatic `company_slug` generation
- Uses `SlugService::generateUnique()`
**Vehicle (Enhanced)**
- Uses `SlugService` in controller for `slug` generation
- Primary image relationship optimized
#### Migration Files
**Migrations/2026_04_23_xxxxxx_create_user_notifications_table.php**
```php
Schema::create('user_notifications', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('title');
$table->text('message');
$table->enum('type', ['info', 'success', 'warning', 'error']);
$table->string('reference_type')->nullable();
$table->unsignedBigInteger('reference_id')->nullable();
$table->string('url')->nullable();
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'read_at']);
$table->index('created_at');
});
```
**Migrations/2026_04_23_xxxxxx_create_activity_logs_table.php**
```php
Schema::create('activity_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnDelete();
$table->string('action');
$table->string('target_type');
$table->unsignedBigInteger('target_id');
$table->text('description');
$table->json('meta')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index('user_id');
$table->index('action');
$table->index('created_at');
$table->index(['target_type', 'target_id']);
});
```
---
### 3. Policies Layer
#### 6 Policy Classes
**VehiclePolicy**
```php
// Location: app/Policies/VehiclePolicy.php
public function view(User $user, Vehicle $vehicle): bool
// admin_rental owns rental_company OR super_admin
public function create(User $user): bool
// admin_rental must have rental_company
public function update(User $user, Vehicle $vehicle): bool
// Delegates to view()
public function delete(User $user, Vehicle $vehicle): bool
// Delegates to view()
```
**BookingPolicy**
```php
// Location: app/Policies/BookingPolicy.php
public function view(User $user, Booking $booking): bool
// customer owns booking OR admin_rental owns rental_company OR super_admin
public function create(User $user): bool
// customer only
public function update(User $user, Booking $booking): bool
// Delegates to view()
```
**PaymentPolicy**
```php
// Location: app/Policies/PaymentPolicy.php
public function view(User $user, Payment $payment): bool
// customer owns booking OR admin_rental owns rental_company OR super_admin
public function update(User $user, Payment $payment): bool
// Delegates to view()
```
**PromoPolicy**
```php
// Location: app/Policies/PromoPolicy.php
public function view(User $user, Promo $promo): bool
// admin_rental owns rental_company OR super_admin
public function create(User $user): bool
// admin_rental must have rental_company
public function update(User $user, Promo $promo): bool
// Delegates to view()
public function delete(User $user, Promo $promo): bool
// Delegates to view()
```
**ReviewPolicy**
```php
// Location: app/Policies/ReviewPolicy.php
public function view(User $user, Review $review): bool
// customer/admin_rental/super_admin with access
public function create(User $user, Booking $booking): bool
// customer owns completed booking with no existing review
```
**RentalCompanyPolicy**
```php
// Location: app/Policies/RentalCompanyPolicy.php
public function view(User $user, RentalCompany $company): bool
// admin_rental owner OR super_admin
public function verify(User $user): bool
// super_admin only
public function update(User $user, RentalCompany $company): bool
// Delegates to view()
```
**Registration di AppServiceProvider**
```php
// Location: app/Providers/AppServiceProvider.php
protected $policies = [
Vehicle::class => VehiclePolicy::class,
Booking::class => BookingPolicy::class,
Payment::class => PaymentPolicy::class,
Promo::class => PromoPolicy::class,
Review::class => ReviewPolicy::class,
RentalCompany::class => RentalCompanyPolicy::class,
];
```
---
### 4. Controllers Refactoring
#### AdminRental/VehicleController
**Changes:**
- ✅ Added `SlugService` dependency injection
- ✅ Added `FileUploadService` dependency injection
- ✅ Added `ActivityLogService` integration (prep for Tahap 10)
- ✅ All CRUD methods use `$this->authorize()` checks
- ✅ File upload with try-catch error handling
- ✅ Dated subdirectory organization for uploads
**Methods:**
```php
public function __construct(
private readonly SlugService $slugService,
private readonly FileUploadService $fileUploadService,
private readonly ActivityLogService $activityLogService,
) {}
public function index(): View
public function create(): View
public function store(StoreVehicleRequest $request): RedirectResponse
public function edit(Vehicle $vehicle): View
public function update(UpdateVehicleRequest $request, Vehicle $vehicle): RedirectResponse
public function destroy(Vehicle $vehicle): RedirectResponse
public function destroyGalleryImage(VehicleImage $image): RedirectResponse
```
#### Customer/BookingController
**Changes:**
- ✅ Added authorization checks
- ✅ NotificationService integration (Tahap 10)
- ✅ ActivityLogService integration (Tahap 10)
#### Customer/PaymentController
**Changes:**
- ✅ Added `FileUploadService` dependency
- ✅ File upload validation tightened
- ✅ MIME type & size checking
- ✅ Safe file deletion on update
- ✅ Authorization checks on all methods
- ✅ Notification/Logging (Tahap 10)
#### Customer/ReviewController
**Changes:**
- ✅ Policy-based authorization replacing manual checks
- ✅ Service injection ready for Tahap 10
#### AdminRental/BookingController
**Changes:**
- ✅ Authorization on show/update methods
- ✅ Service integration ready
#### AdminRental/PaymentController
**Changes:**
- ✅ Authorization checks on all methods
- ✅ Service integration for verify/reject
#### AdminRental/PromoController
**Changes:**
- ✅ Policies on all CRUD methods
- ✅ Service integration ready
#### AdminRental/ReviewController
**Changes:**
- ✅ Authorization check on index
#### AdminRental/CustomerController
**Changes:**
- ✅ Authorization check on index
#### Customer/MyBookingController
**Changes:**
- ✅ Policy-based authorization
---
### 5. Form Validation Hardening
#### Vehicle Validation (StoreVehicleRequest)
```php
'thumbnail' => ['required', 'file', 'mimetypes:image/jpeg,image/png', 'max:4096'],
'gallery_images.*' => ['nullable', 'file', 'mimetypes:image/jpeg,image/png', 'max:4096'],
```
#### Payment Validation (UploadProofRequest)
```php
'proof_file' => [
'required',
'file',
'mimetypes:image/jpeg,image/png,application/pdf',
'max:8192',
],
```
#### Promo Validation (StorePromoRequest)
```php
'title' => ['required', 'string', 'max:100'],
'promo_code' => ['required', 'string', 'max:20', 'unique:promos'],
'discount_value' => ['required', 'numeric', 'min:0'],
```
---
## Implementation Details
### SlugService Usage
**Di VehicleController:**
```php
$slug = $this->slugService->generateUnique(
Vehicle::class,
'slug',
$validated['name']
);
$vehicle = Vehicle::create([
...$validated,
'slug' => $slug,
]);
```
**Di RentalCompany Model (Boot Event):**
```php
protected static function boot(): void
{
parent::boot();
static::creating(function (self $model): void {
if (!$model->company_slug) {
$slug = app(SlugService::class)->generateUnique(
self::class,
'company_slug',
$model->company_name
);
$model->company_slug = $slug;
}
});
}
```
### FileUploadService Usage
**Store File:**
```php
try {
$path = $this->fileUploadService->storePublic(
$request->file('thumbnail'),
'vehicles/main'
);
// Path: "vehicles/main/2026/04/uuid.jpg"
} catch (\Throwable $exception) {
return back()->withInput()->with('error', 'Upload gagal');
}
```
**Delete File:**
```php
$this->fileUploadService->deletePublic($vehicle->thumbnail);
```
### Authorization Usage
**Via Policy:**
```php
// Single action
$this->authorize('view', $vehicle);
// Class-level
$this->authorize('create', Vehicle::class);
// Custom gate
$this->authorize('verify', $rentalCompany);
```
---
## Technical Architecture
### Folder Structure (After Tahap 9)
```
app/
├── Services/
│ ├── PromoService.php (Existing)
│ ├── SlugService.php (New)
│ ├── FileUploadService.php (New)
│ ├── NotificationService.php (New - Tahap 10 prep)
│ └── ActivityLogService.php (New - Tahap 10 prep)
├── Policies/
│ ├── VehiclePolicy.php
│ ├── BookingPolicy.php
│ ├── PaymentPolicy.php
│ ├── PromoPolicy.php
│ ├── ReviewPolicy.php
│ └── RentalCompanyPolicy.php
├── Models/
│ ├── User.php (Updated)
│ ├── Vehicle.php (Updated)
│ ├── Booking.php (Updated)
│ ├── Payment.php (Updated)
│ ├── Promo.php (Updated)
│ ├── Review.php (Updated)
│ ├── RentalCompany.php (Updated)
│ ├── UserNotification.php (New)
│ └── ActivityLog.php (New)
├── Http/
│ ├── Requests/ (Validation tightened)
│ └── Controllers/ (Refactored dengan service injection)
└── Providers/
└── AppServiceProvider.php (Policies registered)
database/
├── migrations/
│ ├── 2026_04_23_xxxxxx_create_user_notifications_table.php
│ └── 2026_04_23_xxxxxx_create_activity_logs_table.php
└── seeders/ (Unchanged)
```
### Design Patterns
#### 1. Service Layer Pattern
Business logic abstracted from controllers:
```
Controller → Service → Model → Database
```
**Benefits:**
- Reusability across controllers
- Testability
- Single responsibility
- Easy to mock
#### 2. Policy Pattern
Authorization logic centralized:
```
$this->authorize('action', Model) → Policy → User Role Check
```
**Benefits:**
- Consistent authorization
- Maintainable permissions
- Testable policies
#### 3. Dependency Injection
Constructor injection untuk semua services:
```php
public function __construct(
private readonly ServiceA $serviceA,
private readonly ServiceB $serviceB,
) {}
```
#### 4. Immutable Logging
ActivityLog append-only (no updates):
```
Created → Never Updated → Audit Trail Safe
```
---
## Database Relationships
```
Users (1) ──→ (Many) UserNotifications
(1) ──→ (Many) ActivityLogs
(1) ──→ (1) RentalCompany
(1) ──→ (Many) Bookings
RentalCompany (1) ──→ (Many) Vehicles
(1) ──→ (Many) Promos
(1) ──→ (Many) Bookings
Bookings (1) ──→ (1) Payment
(1) ──→ (1) Review
(1) ──→ (1) Vehicle
Vehicles (1) ──→ (Many) VehicleImages
(1) ──→ (Many) Bookings
```
---
## Validation Rules Summary
### Vehicle Upload
- Format: JPEG, PNG
- Size: Max 4MB per image
- Required: Thumbnail
- Optional: Gallery (multiple)
### Payment Upload
- Format: JPEG, PNG, PDF
- Size: Max 8MB
- Required: File type check
- Transaction: Atomic with DB update
### Promo Creation
- Code: Unique, uppercase, max 20 chars
- Title: Required, max 100 chars
- Discount: Numeric, non-negative
- Dates: End date >= Start date
---
## Error Handling
### FileUploadService
```php
try {
$path = $fileUploadService->storePublic($file, $dir);
} catch (\Throwable $e) {
// User-friendly error message
return back()->with('error', 'Upload failed');
}
```
### Authorization
```php
try {
$this->authorize('action', $model);
} catch (AuthorizationException $e) {
// Laravel handles: 403 Forbidden response
}
```
### Validation
```php
// FormRequest handles automatically
// Returns back with errors on validation fail
```
---
## Performance Considerations
1. **Slug Generation**: Single query per unique check (optimized)
2. **File Upload**: UUID prevents lookups, dated dirs enable cleanup
3. **Activity Logs**: Append-only (no locks), indexes on common queries
4. **Policies**: Eager loading relationships to avoid N+1
5. **Queries**: withCount, withSum untuk statistics tanpa extra queries
---
## Testing Checklist
- [ ] SlugService generates unique slugs
- [ ] SlugService handles collisions
- [ ] FileUploadService stores with UUID naming
- [ ] FileUploadService deletes safely
- [ ] Policies correctly authorize actions
- [ ] Services inject correctly in controllers
- [ ] File validation rejects invalid types
- [ ] File validation enforces size limits
- [ ] RentalCompany auto-generates slug
- [ ] Activity logs capture correctly (Tahap 10)
- [ ] Notifications send correctly (Tahap 10)
---
## Tahap 9 Summary
✅ **COMPLETED:**
- 4 Services (Promo, Slug, FileUpload, Notification/ActivityLog prep)
- 6 Policies with consistent pattern
- 8 Controllers refactored with service injection
- 2 New models with migrations
- Validation hardening
- Authorization layer implementation
⏳ **NEXT (Tahap 10):**
- Notification integration (8 triggers)
- Activity logging integration (15+ actions)
- Error pages & components
- Report filtering enhancements
- UI/UX polish
---
## Quick Reference
### Service Injection
```php
use App\Services\SlugService;
use App\Services\FileUploadService;
public function __construct(
private readonly SlugService $slugService,
private readonly FileUploadService $fileUploadService,
) {}
```
### Authorization Check
```php
$this->authorize('action', $model);
$this->authorize('create', ModelClass::class);
```
### Slug Generation
```php
$slug = $this->slugService->generateUnique(
Vehicle::class, 'slug', $sourceName
);
```
### File Upload
```php
$path = $this->fileUploadService->storePublic(
$request->file('field'), 'directory'
);
```
---
**Dokumentasi Tahap 9 Complete**
Untuk Tahap 10, lihat: `TAHAP_10_DOKUMENTASI.md`

View File

@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Models\Inventory;
use App\Models\Vehicle;
use Illuminate\Console\Command;
class SyncVehicleInventories extends Command
{
protected $signature = 'inventory:sync';
protected $description = 'Buat record inventoris untuk kendaraan yang belum memilikinya';
public function handle(): void
{
$vehicles = Vehicle::whereDoesntHave('inventory')->get();
if ($vehicles->isEmpty()) {
$this->info('Semua kendaraan sudah memiliki inventoris.');
return;
}
$this->info("Ditemukan {$vehicles->count()} kendaraan tanpa inventoris. Memproses...");
foreach ($vehicles as $vehicle) {
Inventory::create([
'rental_company_id' => $vehicle->rental_company_id,
'vehicle_id' => $vehicle->id,
'total' => 0,
'reserved' => 0,
'available' => 0,
]);
$this->line(" - {$vehicle->name}");
}
$this->info('Selesai! Inventoris berhasil dibuat untuk semua kendaraan.');
}
}

View File

@ -0,0 +1,330 @@
<?php
namespace App\Http\Controllers\AdminRental;
use App\Http\Controllers\Controller;
use App\Http\Requests\CancelBookingRequest;
use App\Models\Booking;
use App\Models\Inventory;
use App\Models\Payment;
use App\Models\RentalCompany;
use App\Services\ActivityLogService;
use App\Services\NotificationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class BookingController extends Controller
{
public function __construct(
private readonly NotificationService $notificationService,
private readonly ActivityLogService $activityLogService,
) {
}
public function index(Request $request): View|RedirectResponse
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company.');
}
$request->validate([
'booking_status' => ['nullable', 'in:waiting_payment,waiting_verification,confirmed,ongoing,completed,cancelled'],
'payment_status' => ['nullable', 'in:unpaid,uploaded,verified,rejected'],
'search' => ['nullable', 'string', 'max:100'],
]);
$bookings = Booking::query()
->with(['vehicle', 'customer', 'payment'])
->where('rental_company_id', $rentalCompany->id)
->when($request->filled('booking_status'), function ($query) use ($request): void {
$query->where('booking_status', $request->string('booking_status')->toString());
})
->when($request->filled('payment_status'), function ($query) use ($request): void {
$query->where('payment_status', $request->string('payment_status')->toString());
})
->when($request->filled('search'), function ($query) use ($request): void {
$search = $request->string('search')->toString();
$query->where(function ($subQuery) use ($search): void {
$subQuery->where('booking_code', 'like', '%' . $search . '%')
->orWhere('customer_name', 'like', '%' . $search . '%')
->orWhereHas('vehicle', function ($vehicleQuery) use ($search): void {
$vehicleQuery->where('name', 'like', '%' . $search . '%');
});
});
})
->latest('id')
->paginate(10)
->withQueryString();
$bookingStatusOptions = Booking::statusOptions();
$paymentStatusOptions = Booking::paymentStatusOptions();
return view('admin-rental.bookings.index', compact('bookings', 'rentalCompany', 'bookingStatusOptions', 'paymentStatusOptions'));
}
public function show(Booking $booking): View|RedirectResponse
{
$this->authorize('view', $booking);
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company.');
}
$this->ensureBookingBelongsToRental($booking, $rentalCompany->id);
$booking->load(['customer', 'vehicle.rentalCompany', 'vehicle.primaryImage', 'vehicle.inventory', 'payment']);
return view('admin-rental.bookings.show', compact('booking', 'rentalCompany'));
}
public function markOngoing(Booking $booking): RedirectResponse
{
$this->authorize('update', $booking);
$rentalCompany = $this->getRentalCompanyOrAbort();
$this->ensureBookingBelongsToRental($booking, $rentalCompany->id);
if ($booking->booking_status !== Booking::BOOKING_CONFIRMED || $booking->payment_status !== Booking::PAYMENT_VERIFIED) {
return back()->with('error', 'Booking hanya bisa diubah ke ongoing jika status confirmed dan pembayaran verified.');
}
$booking->update([
'booking_status' => Booking::BOOKING_ONGOING,
]);
$this->notificationService->notifyUser(
userId: (int) $booking->customer_id,
title: 'Status Booking Berubah',
message: 'Booking ' . $booking->booking_code . ' sedang berjalan (ongoing).',
type: 'info',
url: route('customer.bookings.show', $booking),
referenceType: 'booking',
referenceId: $booking->id,
);
$this->activityLogService->log(
action: 'booking.marked_ongoing',
description: 'Admin rental mengubah booking ke ongoing: ' . $booking->booking_code,
targetType: 'booking',
targetId: $booking->id
);
return back()->with('success', 'Booking berhasil diubah menjadi ongoing.');
}
public function confirmAvailability(Booking $booking): RedirectResponse
{
$this->authorize('update', $booking);
$rentalCompany = $this->getRentalCompanyOrAbort();
$this->ensureBookingBelongsToRental($booking, $rentalCompany->id);
if (!in_array($booking->booking_status, [Booking::BOOKING_WAITING_VERIFICATION, Booking::BOOKING_WAITING_PAYMENT, Booking::BOOKING_PENDING], true)) {
return back()->with('error', 'Booking tidak dapat dikonfirmasi pada status saat ini.');
}
DB::beginTransaction();
try {
$inventory = Inventory::where('rental_company_id', $rentalCompany->id)
->where('vehicle_id', $booking->vehicle_id)
->lockForUpdate()
->first();
if (! $inventory || ($inventory->available ?? 0) <= 0) {
DB::rollBack();
return back()->with('error', 'Stok tidak tersedia untuk kendaraan ini.');
}
// reserve one unit
$inventory->reserved = max(0, ($inventory->reserved ?? 0) + 1);
$inventory->available = max(0, ($inventory->available ?? 0) - 1);
$inventory->last_checked_by = Auth::id();
$inventory->save();
$booking->update([
'booking_status' => Booking::BOOKING_CONFIRMED,
]);
$this->notificationService->notifyUser(
userId: (int) $booking->customer_id,
title: 'Booking Dikonfirmasi',
message: 'Booking ' . $booking->booking_code . ' dikonfirmasi dan barang dipesan.',
type: 'success',
url: route('customer.bookings.show', $booking),
referenceType: 'booking',
referenceId: $booking->id,
);
$this->activityLogService->log(
action: 'booking.confirmed',
description: 'Admin rental mengkonfirmasi booking: ' . $booking->booking_code,
targetType: 'booking',
targetId: $booking->id
);
DB::commit();
return back()->with('success', 'Booking berhasil dikonfirmasi dan stok di-reserve.');
} catch (\Throwable $e) {
DB::rollBack();
report($e);
return back()->with('error', 'Terjadi kesalahan saat mengonfirmasi booking.');
}
}
public function rejectAvailability(Request $request, Booking $booking): RedirectResponse
{
$this->authorize('update', $booking);
$rentalCompany = $this->getRentalCompanyOrAbort();
$this->ensureBookingBelongsToRental($booking, $rentalCompany->id);
if (!in_array($booking->booking_status, [Booking::BOOKING_WAITING_VERIFICATION, Booking::BOOKING_WAITING_PAYMENT, Booking::BOOKING_PENDING], true)) {
return back()->with('error', 'Booking tidak dapat ditolak pada status saat ini.');
}
$note = $request->filled('reason') ? $request->string('reason')->toString() : null;
DB::transaction(function () use ($booking, $note) {
if ($booking->payment) {
$booking->payment->update([
'payment_status' => Payment::STATUS_REJECTED,
'rejection_note' => $note ?? 'Pembayaran dibatalkan karena booking dibatalkan oleh admin.',
]);
}
$booking->update([
'booking_status' => Booking::BOOKING_CANCELLED,
'payment_status' => Booking::PAYMENT_REJECTED,
'note' => $note ? trim(($booking->note ? $booking->note . PHP_EOL : '') . 'Reject reason: ' . $note) : $booking->note,
]);
$this->notificationService->notifyUser(
userId: (int) $booking->customer_id,
title: 'Booking Dibatalkan',
message: 'Booking ' . $booking->booking_code . ' telah dibatalkan oleh admin. Alasan: ' . ($note ?? '—'),
type: 'warning',
url: route('customer.bookings.show', $booking),
referenceType: 'booking',
referenceId: $booking->id,
);
$this->activityLogService->log(
action: 'booking.cancelled',
description: 'Admin rental membatalkan booking: ' . $booking->booking_code,
targetType: 'booking',
targetId: $booking->id,
meta: ['reason' => $note]
);
});
return back()->with('success', 'Booking telah dibatalkan dan pembayaran ditandai rejected.');
}
public function markCompleted(Booking $booking): RedirectResponse
{
$this->authorize('update', $booking);
$rentalCompany = $this->getRentalCompanyOrAbort();
$this->ensureBookingBelongsToRental($booking, $rentalCompany->id);
if ($booking->booking_status !== Booking::BOOKING_ONGOING) {
return back()->with('error', 'Booking hanya bisa diubah ke completed jika status saat ini ongoing.');
}
$booking->update([
'booking_status' => Booking::BOOKING_COMPLETED,
]);
$this->notificationService->notifyUser(
userId: (int) $booking->customer_id,
title: 'Booking Selesai',
message: 'Booking ' . $booking->booking_code . ' telah selesai. Anda bisa memberikan ulasan.',
type: 'success',
url: route('customer.bookings.show', $booking),
referenceType: 'booking',
referenceId: $booking->id,
);
$this->activityLogService->log(
action: 'booking.marked_completed',
description: 'Admin rental menyelesaikan booking: ' . $booking->booking_code,
targetType: 'booking',
targetId: $booking->id
);
return back()->with('success', 'Booking berhasil diselesaikan (completed).');
}
public function cancel(CancelBookingRequest $request, Booking $booking): RedirectResponse
{
$this->authorize('update', $booking);
$rentalCompany = $this->getRentalCompanyOrAbort();
$this->ensureBookingBelongsToRental($booking, $rentalCompany->id);
if (!in_array($booking->booking_status, [
Booking::BOOKING_WAITING_PAYMENT,
Booking::BOOKING_WAITING_VERIFICATION,
Booking::BOOKING_CONFIRMED,
], true)) {
return back()->with('error', 'Booking hanya bisa dibatalkan pada status waiting_payment, waiting_verification, atau confirmed.');
}
// Payment status tidak diubah pada tahap ini untuk menjaga konsistensi data pembayaran.
$booking->update([
'booking_status' => Booking::BOOKING_CANCELLED,
'note' => $request->filled('cancel_reason')
? trim(($booking->note ? $booking->note . PHP_EOL : '') . 'Cancel reason: ' . $request->string('cancel_reason')->toString())
: $booking->note,
]);
$this->notificationService->notifyUser(
userId: (int) $booking->customer_id,
title: 'Booking Dibatalkan',
message: 'Booking ' . $booking->booking_code . ' telah dibatalkan oleh admin rental.',
type: 'warning',
url: route('customer.bookings.show', $booking),
referenceType: 'booking',
referenceId: $booking->id,
);
$this->activityLogService->log(
action: 'booking.cancelled',
description: 'Admin rental membatalkan booking: ' . $booking->booking_code,
targetType: 'booking',
targetId: $booking->id,
meta: ['cancel_reason' => $request->string('cancel_reason')->toString()]
);
return back()->with('success', 'Booking berhasil dibatalkan.');
}
private function getRentalCompany(): ?RentalCompany
{
return Auth::user()?->rentalCompany;
}
private function getRentalCompanyOrAbort(): RentalCompany
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
abort(404);
}
return $rentalCompany;
}
private function ensureBookingBelongsToRental(Booking $booking, int $rentalCompanyId): void
{
if ((int) $booking->rental_company_id !== $rentalCompanyId) {
abort(404);
}
}
}

View File

@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers\AdminRental;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\RentalCompany;
use App\Models\Review;
use App\Models\User;
use App\Services\PromoService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class CustomerController extends Controller
{
public function index(Request $request): View|RedirectResponse
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()->route('admin-rental.dashboard')->with('error', 'Akun admin rental ini belum memiliki rental company.');
}
$this->authorize('viewAny', User::class);
$request->validate([
'search' => ['nullable', 'string', 'max:100'],
'loyal' => ['nullable', 'in:loyal,non_loyal'],
]);
$threshold = PromoService::LOYAL_COMPLETED_THRESHOLD;
$loyalCustomerIdSubQuery = Booking::query()
->select('customer_id')
->where('rental_company_id', $rentalCompany->id)
->where('booking_status', Booking::BOOKING_COMPLETED)
->groupBy('customer_id')
->havingRaw('COUNT(*) >= ?', [$threshold]);
$customers = User::query()
->whereHas('bookings', function (Builder $query) use ($rentalCompany): void {
$query->where('rental_company_id', $rentalCompany->id);
})
->withCount([
'bookings as booking_count' => function (Builder $query) use ($rentalCompany): void {
$query->where('rental_company_id', $rentalCompany->id);
},
'bookings as completed_booking_count' => function (Builder $query) use ($rentalCompany): void {
$query->where('rental_company_id', $rentalCompany->id)
->where('booking_status', Booking::BOOKING_COMPLETED);
},
])
->withSum([
'bookings as total_transaction_amount' => function (Builder $query) use ($rentalCompany): void {
$query->where('rental_company_id', $rentalCompany->id);
},
], 'total_amount')
->withMax([
'bookings as last_booking_at' => function (Builder $query) use ($rentalCompany): void {
$query->where('rental_company_id', $rentalCompany->id);
},
], 'created_at')
->withAvg([
'reviews as average_rating_given' => function (Builder $query) use ($rentalCompany): void {
$query->where('rental_company_id', $rentalCompany->id);
},
], 'rating')
->when($request->filled('search'), function (Builder $query) use ($request): void {
$search = $request->string('search')->toString();
$query->where(function (Builder $subQuery) use ($search): void {
$subQuery->where('name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%');
});
})
->when($request->string('loyal')->toString() === 'loyal', function (Builder $query) use ($loyalCustomerIdSubQuery): void {
$query->whereIn('id', $loyalCustomerIdSubQuery);
})
->when($request->string('loyal')->toString() === 'non_loyal', function (Builder $query) use ($loyalCustomerIdSubQuery): void {
$query->whereNotIn('id', $loyalCustomerIdSubQuery);
})
->orderByDesc('last_booking_at')
->paginate(10)
->withQueryString();
$summary = [
'total_customers' => Booking::query()
->where('rental_company_id', $rentalCompany->id)
->distinct('customer_id')
->count('customer_id'),
'loyal_customers' => DB::query()->fromSub($loyalCustomerIdSubQuery, 'loyal_customers')->count(),
'completed_bookings' => Booking::query()
->where('rental_company_id', $rentalCompany->id)
->where('booking_status', Booking::BOOKING_COMPLETED)
->count(),
];
return view('admin-rental.customers.index', compact('customers', 'rentalCompany', 'summary', 'threshold'));
}
public function show(User $customer): View
{
$rentalCompany = $this->getRentalCompanyOrAbort();
$bookingsQuery = Booking::query()
->with(['vehicle', 'payment', 'review', 'promo'])
->where('rental_company_id', $rentalCompany->id)
->where('customer_id', $customer->id)
->latest('id');
if (!(clone $bookingsQuery)->exists()) {
abort(404);
}
$threshold = PromoService::LOYAL_COMPLETED_THRESHOLD;
$bookingCount = (clone $bookingsQuery)->count();
$completedCount = (clone $bookingsQuery)->where('booking_status', Booking::BOOKING_COMPLETED)->count();
$totalTransactionAmount = (float) ((clone $bookingsQuery)->sum('total_amount') ?? 0);
$lastBooking = (clone $bookingsQuery)->first();
$lastBookingDate = $lastBooking?->created_at;
$vehicles = (clone $bookingsQuery)
->with('vehicle')
->distinct('vehicle_id')
->get()
->pluck('vehicle')
->unique('id')
->sortBy('name');
$reviews = Review::query()
->with(['vehicle', 'booking'])
->where('rental_company_id', $rentalCompany->id)
->where('customer_id', $customer->id)
->latest('id')
->get();
$bookings = $bookingsQuery->paginate(8);
$isLoyal = $completedCount >= $threshold;
return view('admin-rental.customers.show', compact(
'customer',
'rentalCompany',
'bookings',
'bookingCount',
'completedCount',
'totalTransactionAmount',
'lastBookingDate',
'vehicles',
'reviews',
'threshold',
'isLoyal'
));
}
private function getRentalCompany(): ?RentalCompany
{
return Auth::user()?->rentalCompany;
}
private function getRentalCompanyOrAbort(): RentalCompany
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
abort(404);
}
return $rentalCompany;
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers\AdminRental;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\Vehicle;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(): View|RedirectResponse
{
$user = Auth::user();
$rentalCompany = $user?->rentalCompany;
// Guard against manually-created admin accounts that are not linked to a rental company yet.
if (!$rentalCompany) {
return redirect()
->route('home')
->with('warning', 'Akun admin rental belum terhubung ke data rental company. Silakan daftar rental terlebih dahulu.');
}
$rentalId = (int) $rentalCompany->id;
$totalVehicles = Vehicle::query()
->where('rental_company_id', $rentalId)
->count();
$activeBookings = Booking::query()
->where('rental_company_id', $rentalId)
->whereIn('booking_status', [
Booking::BOOKING_CONFIRMED,
Booking::BOOKING_ONGOING,
])
->count();
$totalCustomers = Booking::query()
->where('rental_company_id', $rentalId)
->distinct('customer_id')
->count('customer_id');
$startOfMonth = Carbon::now()->startOfMonth();
$endOfMonth = Carbon::now()->endOfMonth();
$verifiedRevenueThisMonth = (float) Booking::query()
->where('rental_company_id', $rentalId)
->where('payment_status', Booking::PAYMENT_VERIFIED)
->whereBetween('created_at', [$startOfMonth, $endOfMonth])
->sum('total_amount');
$paymentsWaitingVerification = Booking::query()
->where('rental_company_id', $rentalId)
->where('payment_status', Booking::PAYMENT_UPLOADED)
->count();
$bookingsToday = Booking::query()
->where('rental_company_id', $rentalId)
->whereDate('created_at', Carbon::today())
->count();
$summary = [
['label' => 'Total Kendaraan', 'value' => number_format($totalVehicles, 0, ',', '.')],
['label' => 'Booking Aktif', 'value' => number_format($activeBookings, 0, ',', '.')],
['label' => 'Customer', 'value' => number_format($totalCustomers, 0, ',', '.')],
['label' => 'Pendapatan Verified Bulan Ini', 'value' => 'Rp ' . number_format($verifiedRevenueThisMonth, 0, ',', '.')],
['label' => 'Payment Menunggu Verifikasi', 'value' => number_format($paymentsWaitingVerification, 0, ',', '.')],
['label' => 'Booking Hari Ini', 'value' => number_format($bookingsToday, 0, ',', '.')],
];
return view('admin-rental.dashboard', compact('summary'));
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\AdminRental;
use App\Http\Controllers\Controller;
use App\Models\Inventory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class InventoryController extends Controller
{
/**
* Display a listing of the inventories for the logged-in rental company.
*/
public function index()
{
$user = Auth::user();
$rentalCompany = $user->rentalCompany;
if (! $rentalCompany) {
return redirect()->route('home')->with('warning', 'Akun belum terhubung dengan rental.');
}
$inventories = Inventory::with('vehicle')
->where('rental_company_id', $rentalCompany->id)
->paginate(20);
return view('admin-rental.inventories.index', compact('inventories'));
}
/**
* Update the specified inventory (e.g., adjust counts, reserve/unreserve).
*/
public function update(Request $request, Inventory $inventory)
{
$user = Auth::user();
if ($user->rentalCompany->id !== $inventory->rental_company_id) {
abort(403);
}
$data = $request->validate([
'total' => 'nullable|integer|min:0',
'reserved' => 'nullable|integer|min:0',
'available' => 'nullable|integer|min:0',
'notes' => 'nullable|string',
]);
$inventory->fill($data);
$inventory->last_checked_by = $user->id;
$inventory->save();
return back()->with('success', 'Inventory updated.');
}
/**
* Display the specified inventory with simple edit form.
*/
public function show(Inventory $inventory)
{
$user = Auth::user();
if ($user->rentalCompany->id !== $inventory->rental_company_id) {
abort(403);
}
return view('admin-rental.inventories.show', compact('inventory'));
}
}

View File

@ -0,0 +1,177 @@
<?php
namespace App\Http\Controllers\AdminRental;
use App\Http\Controllers\Controller;
use App\Http\Requests\RejectPaymentRequest;
use App\Models\Booking;
use App\Models\Payment;
use App\Models\RentalCompany;
use App\Services\ActivityLogService;
use App\Services\NotificationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class PaymentController extends Controller
{
public function __construct(
private readonly NotificationService $notificationService,
private readonly ActivityLogService $activityLogService,
) {}
public function index(): View|RedirectResponse
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company.');
}
$bookings = Booking::query()
->with(['customer', 'vehicle', 'payment'])
->where('rental_company_id', $rentalCompany->id)
->whereHas('payment')
// Exclude bookings that have been cancelled so payments won't show for cancelled bookings
->where('booking_status', '!=', Booking::BOOKING_CANCELLED)
->latest('id')
->paginate(10);
return view('admin-rental.payments.index', compact('bookings', 'rentalCompany'));
}
public function show(Booking $booking): View|RedirectResponse
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company.');
}
$this->ensureBookingBelongsToRental($booking, $rentalCompany->id);
$booking->load(['customer', 'vehicle.rentalCompany', 'vehicle.primaryImage', 'payment']);
return view('admin-rental.payments.show', compact('booking', 'rentalCompany'));
}
public function verify(Booking $booking): RedirectResponse
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company.');
}
$this->ensureBookingBelongsToRental($booking, $rentalCompany->id);
$booking->load('payment');
if (!$booking->payment || $booking->payment->payment_status !== Payment::STATUS_UPLOADED) {
return back()->with('error', 'Pembayaran harus berstatus uploaded sebelum diverifikasi.');
}
DB::transaction(function () use ($booking): void {
$booking->payment->update([
'payment_status' => Payment::STATUS_VERIFIED,
'verified_by' => Auth::id(),
'verified_at' => now(),
'rejection_note' => null,
]);
$booking->update([
'payment_status' => Booking::PAYMENT_VERIFIED,
'booking_status' => Booking::BOOKING_CONFIRMED,
]);
$this->notificationService->notifyUser(
userId: (int) $booking->customer_id,
title: 'Pembayaran Diverifikasi',
message: 'Bukti pembayaran untuk booking ' . $booking->booking_code . ' telah diverifikasi. Booking Anda dikonfirmasi.',
type: 'success',
url: route('customer.bookings.show', $booking),
referenceType: 'booking',
referenceId: $booking->id,
);
$this->activityLogService->log(
action: 'payment.verified',
description: 'Admin rental memverifikasi pembayaran booking: ' . $booking->booking_code,
targetType: 'payment',
targetId: $booking->payment->id,
meta: ['booking_id' => $booking->id, 'amount' => $booking->payment->amount]
);
});
return back()->with('success', 'Pembayaran berhasil diverifikasi.');
}
public function reject(RejectPaymentRequest $request, Booking $booking): RedirectResponse
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company.');
}
$this->ensureBookingBelongsToRental($booking, $rentalCompany->id);
$booking->load('payment');
if (!$booking->payment || $booking->payment->payment_status !== Payment::STATUS_UPLOADED) {
return back()->with('error', 'Pembayaran harus berstatus uploaded sebelum ditolak.');
}
$validated = $request->validated();
DB::transaction(function () use ($booking, $validated): void {
$booking->payment->update([
'payment_status' => Payment::STATUS_REJECTED,
'rejection_note' => $validated['rejection_note'],
]);
$booking->update([
'payment_status' => Booking::PAYMENT_REJECTED,
'booking_status' => Booking::BOOKING_WAITING_PAYMENT,
]);
$this->notificationService->notifyUser(
userId: (int) $booking->customer_id,
title: 'Pembayaran Ditolak',
message: 'Bukti pembayaran untuk booking ' . $booking->booking_code . ' ditolak. Alasan: ' . $validated['rejection_note'],
type: 'error',
url: route('customer.bookings.show', $booking),
referenceType: 'booking',
referenceId: $booking->id,
);
$this->activityLogService->log(
action: 'payment.rejected',
description: 'Admin rental menolak pembayaran booking: ' . $booking->booking_code,
targetType: 'payment',
targetId: $booking->payment->id,
meta: ['booking_id' => $booking->id, 'rejection_note' => $validated['rejection_note']]
);
});
return back()->with('success', 'Pembayaran berhasil ditolak. Customer dapat upload ulang bukti pembayaran.');
}
private function getRentalCompany(): ?RentalCompany
{
return Auth::user()?->rentalCompany;
}
private function ensureBookingBelongsToRental(Booking $booking, int $rentalCompanyId): void
{
if ((int) $booking->rental_company_id !== $rentalCompanyId) {
abort(404);
}
}
}

View File

@ -0,0 +1,216 @@
<?php
namespace App\Http\Controllers\AdminRental;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePromoRequest;
use App\Http\Requests\UpdatePromoRequest;
use App\Models\Promo;
use App\Models\RentalCompany;
use App\Services\ActivityLogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class PromoController extends Controller
{
public function __construct(
private readonly ActivityLogService $activityLogService,
) {}
public function index(Request $request): View|RedirectResponse
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()->route('admin-rental.dashboard')->with('error', 'Akun admin rental ini belum memiliki rental company.');
}
$request->validate([
'search' => ['nullable', 'string', 'max:100'],
'status' => ['nullable', 'in:active,inactive'],
]);
$promos = Promo::query()
->where('rental_company_id', $rentalCompany->id)
->when($request->filled('search'), function ($query) use ($request): void {
$search = $request->string('search')->toString();
$query->where(function ($subQuery) use ($search): void {
$subQuery->where('title', 'like', '%' . $search . '%')
->orWhere('promo_code', 'like', '%' . strtoupper($search) . '%');
});
})
->when($request->filled('status'), function ($query) use ($request): void {
$query->where('status', $request->string('status')->toString());
})
->latest('id')
->paginate(10)
->withQueryString();
return view('admin-rental.promos.index', compact('promos', 'rentalCompany'));
}
public function create(): View|RedirectResponse
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()->route('admin-rental.dashboard')->with('error', 'Akun admin rental ini belum memiliki rental company.');
}
$this->authorize('create', Promo::class);
$promo = new Promo([
'discount_type' => Promo::DISCOUNT_PERCENT,
'status' => Promo::STATUS_ACTIVE,
'min_transaction' => 0,
'loyal_only' => false,
]);
return view('admin-rental.promos.create', compact('promo', 'rentalCompany'));
}
public function store(StorePromoRequest $request): RedirectResponse
{
$this->authorize('create', Promo::class);
$rentalCompany = $this->getRentalCompanyOrAbort();
$validated = $request->validated();
$promo = Promo::create([
'rental_company_id' => $rentalCompany->id,
'title' => $validated['title'],
'promo_code' => strtoupper($validated['promo_code']),
'description' => $validated['description'] ?? null,
'discount_type' => $validated['discount_type'],
'discount_value' => $validated['discount_value'],
'min_transaction' => $validated['min_transaction'] ?? 0,
'start_date' => $validated['start_date'],
'end_date' => $validated['end_date'],
'quota' => $validated['quota'] ?? null,
'used_count' => 0,
'loyal_only' => $request->boolean('loyal_only'),
'status' => $validated['status'],
]);
$this->activityLogService->log(
action: 'promo.created',
description: 'Admin rental membuat promo: ' . $validated['title'],
targetType: 'promo',
targetId: $promo->id,
meta: ['promo_code' => $promo->promo_code, 'discount_value' => $promo->discount_value]
);
return redirect()->route('admin-rental.promos.index')->with('success', 'Promo berhasil ditambahkan.');
}
public function edit(Promo $promo): View
{
$rentalCompany = $this->getRentalCompanyOrAbort();
$this->ensurePromoBelongsToRental($promo, $rentalCompany->id);
$this->authorize('update', $promo);
return view('admin-rental.promos.edit', compact('promo', 'rentalCompany'));
}
public function update(UpdatePromoRequest $request, Promo $promo): RedirectResponse
{
$this->authorize('update', $promo);
$rentalCompany = $this->getRentalCompanyOrAbort();
$this->ensurePromoBelongsToRental($promo, $rentalCompany->id);
$validated = $request->validated();
$promo->update([
'title' => $validated['title'],
'promo_code' => strtoupper($validated['promo_code']),
'description' => $validated['description'] ?? null,
'discount_type' => $validated['discount_type'],
'discount_value' => $validated['discount_value'],
'min_transaction' => $validated['min_transaction'] ?? 0,
'start_date' => $validated['start_date'],
'end_date' => $validated['end_date'],
'quota' => $validated['quota'] ?? null,
'loyal_only' => $request->boolean('loyal_only'),
'status' => $validated['status'],
]);
$this->activityLogService->log(
action: 'promo.updated',
description: 'Admin rental memperbarui promo: ' . $validated['title'],
targetType: 'promo',
targetId: $promo->id,
meta: ['promo_code' => $promo->promo_code, 'discount_value' => $promo->discount_value]
);
return redirect()->route('admin-rental.promos.index')->with('success', 'Promo berhasil diperbarui.');
}
public function destroy(Promo $promo): RedirectResponse
{
$this->authorize('delete', $promo);
$rentalCompany = $this->getRentalCompanyOrAbort();
$this->ensurePromoBelongsToRental($promo, $rentalCompany->id);
$promoCode = $promo->promo_code;
$promoId = $promo->id;
$promo->delete();
$this->activityLogService->log(
action: 'promo.deleted',
description: 'Admin rental menghapus promo: ' . $promoCode,
targetType: 'promo',
targetId: $promoId,
meta: ['promo_code' => $promoCode]
);
return redirect()->route('admin-rental.promos.index')->with('success', 'Promo berhasil dihapus.');
}
public function toggle(Promo $promo): RedirectResponse
{
$this->authorize('update', $promo);
$rentalCompany = $this->getRentalCompanyOrAbort();
$this->ensurePromoBelongsToRental($promo, $rentalCompany->id);
$oldStatus = $promo->status;
$newStatus = $promo->status === Promo::STATUS_ACTIVE ? Promo::STATUS_INACTIVE : Promo::STATUS_ACTIVE;
$promo->update([
'status' => $newStatus,
]);
$this->activityLogService->log(
action: 'promo.toggled',
description: 'Admin rental mengubah status promo: ' . $promo->promo_code . ' dari ' . $oldStatus . ' ke ' . $newStatus,
targetType: 'promo',
targetId: $promo->id,
meta: ['old_status' => $oldStatus, 'new_status' => $newStatus]
);
return back()->with('success', 'Status promo berhasil diperbarui.');
}
private function getRentalCompany(): ?RentalCompany
{
return Auth::user()?->rentalCompany;
}
private function getRentalCompanyOrAbort(): RentalCompany
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
abort(404);
}
return $rentalCompany;
}
private function ensurePromoBelongsToRental(Promo $promo, int $rentalCompanyId): void
{
if ((int) $promo->rental_company_id !== $rentalCompanyId) {
abort(404);
}
}
}

View File

@ -0,0 +1,303 @@
<?php
namespace App\Http\Controllers\AdminRental;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\User;
use App\Models\Vehicle;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class ReportController extends Controller
{
private float $commissionRate = 10.0; // 10% commission
/**
* Dashboard ringkas laporan rental
*/
public function index(Request $request): View
{
$rentalId = Auth::user()->rentalCompany->id;
$dateFilter = $this->getDateFilter($request);
// Total Booking untuk rental ini
$totalBookings = $this->getBaseBookingsQuery($rentalId, $dateFilter)->count();
// Total Payment Verified
$totalPaymentsVerified = $this->getBaseBookingsQuery($rentalId, $dateFilter)
->where('payment_status', Booking::PAYMENT_VERIFIED)
->count();
// Total Revenue (dari payment verified)
$totalRevenue = (float) ($this->getBaseBookingsQuery($rentalId, $dateFilter)
->where('payment_status', Booking::PAYMENT_VERIFIED)
->sum('total_amount') ?? 0);
// Total Commission (10% dari revenue)
$totalCommission = round($totalRevenue * ($this->commissionRate / 100), 2);
// Total Vehicles (milik rental)
$totalVehicles = Vehicle::where('rental_company_id', $rentalId)->count();
// Total Active Customers (yang pernah booking ke rental ini)
$totalActiveCustomers = User::query()
->where('role', 'customer')
->whereHas('bookings', fn($q) => $q->where('rental_company_id', $rentalId))
->count();
return view('admin-rental.reports.index', compact(
'totalBookings',
'totalPaymentsVerified',
'totalRevenue',
'totalCommission',
'totalVehicles',
'totalActiveCustomers'
));
}
/**
* Laporan Booking Rental
*/
public function bookings(Request $request): View
{
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'booking_status' => ['nullable', 'in:waiting_payment,waiting_verification,confirmed,ongoing,completed,cancelled'],
]);
$rentalId = Auth::user()->rentalCompany->id;
$dateFilter = $this->getDateFilter($request);
$baseQuery = Booking::query()
->where('rental_company_id', $rentalId)
->with(['customer', 'vehicle', 'payment'])
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']))
->when($request->filled('booking_status'), fn($q) => $q->where('booking_status', $request->string('booking_status')));
// Summary statistics
$summary = [
'total_bookings' => (clone $baseQuery)->count(),
'total_completed' => (clone $baseQuery)->where('booking_status', Booking::BOOKING_COMPLETED)->count(),
'total_cancelled' => (clone $baseQuery)->where('booking_status', Booking::BOOKING_CANCELLED)->count(),
'total_ongoing' => (clone $baseQuery)->where('booking_status', Booking::BOOKING_ONGOING)->count(),
];
$bookings = (clone $baseQuery)
->latest('id')
->paginate(15)
->withQueryString();
$bookingStatuses = Booking::statusOptions();
return view('admin-rental.reports.bookings', compact(
'bookings',
'summary',
'bookingStatuses'
));
}
/**
* Laporan Pembayaran Rental
*/
public function payments(Request $request): View
{
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'payment_status' => ['nullable', 'in:unpaid,uploaded,verified,rejected'],
]);
$rentalId = Auth::user()->rentalCompany->id;
$dateFilter = $this->getDateFilter($request);
$baseQuery = Booking::query()
->where('rental_company_id', $rentalId)
->with(['customer', 'vehicle', 'payment'])
->whereHas('payment')
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']))
->when($request->filled('payment_status'), fn($q) => $q->where('payment_status', $request->string('payment_status')));
// Summary statistics
$summary = [
'total_payments_verified' => (clone $baseQuery)->where('payment_status', Booking::PAYMENT_VERIFIED)->count(),
'total_payments_uploaded' => (clone $baseQuery)->where('payment_status', Booking::PAYMENT_UPLOADED)->count(),
'total_payments_rejected' => (clone $baseQuery)->where('payment_status', Booking::PAYMENT_REJECTED)->count(),
'total_nominal_verified' => (float) ((clone $baseQuery)->where('payment_status', Booking::PAYMENT_VERIFIED)->sum('total_amount') ?? 0),
];
$bookings = (clone $baseQuery)
->latest('id')
->paginate(15)
->withQueryString();
$paymentStatuses = Booking::paymentStatusOptions();
return view('admin-rental.reports.payments', compact(
'bookings',
'summary',
'paymentStatuses'
));
}
/**
* Laporan Kendaraan Terlaris Rental
*/
public function topVehicles(Request $request): View
{
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'limit' => ['nullable', 'integer', 'min:5', 'max:100'],
]);
$limit = $request->integer('limit', 20);
$rentalId = Auth::user()->rentalCompany->id;
$dateFilter = $this->getDateFilter($request);
// Kendaraan terlaris berdasarkan jumlah booking dengan payment verified
$topVehicles = Vehicle::query()
->where('rental_company_id', $rentalId)
->withCount([
'bookings as verified_booking_count' => function (Builder $query) use ($dateFilter): void {
$query->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
])
->withSum([
'bookings as total_revenue' => function (Builder $query) use ($dateFilter): void {
$query->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
], 'total_amount')
->having('verified_booking_count', '>', 0)
->orderByDesc('verified_booking_count')
->limit($limit)
->get();
return view('admin-rental.reports.top-vehicles', compact('topVehicles'));
}
/**
* Laporan Customer Aktif Rental
*/
public function activeCustomers(Request $request): View
{
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'limit' => ['nullable', 'integer', 'min:5', 'max:100'],
]);
$limit = $request->integer('limit', 20);
$rentalId = Auth::user()->rentalCompany->id;
$dateFilter = $this->getDateFilter($request);
// Customer aktif berdasarkan completed bookings
$activeCustomers = User::query()
->where('role', 'customer')
->withCount([
'bookings as total_booking_count' => function (Builder $query) use ($rentalId, $dateFilter): void {
$query->where('rental_company_id', $rentalId)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
'bookings as completed_booking_count' => function (Builder $query) use ($rentalId, $dateFilter): void {
$query->where('rental_company_id', $rentalId)
->where('booking_status', Booking::BOOKING_COMPLETED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
])
->withMax([
'bookings as last_booking_date' => function (Builder $query) use ($rentalId, $dateFilter): void {
$query->where('rental_company_id', $rentalId)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
], 'created_at')
->withSum([
'bookings as total_transaction' => function (Builder $query) use ($rentalId, $dateFilter): void {
$query->where('rental_company_id', $rentalId)
->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
], 'total_amount')
->having('completed_booking_count', '>', 0)
->orderByDesc('completed_booking_count')
->limit($limit)
->get();
// Check loyal threshold (3 completed bookings)
$loyalThreshold = 3;
return view('admin-rental.reports.active-customers', compact('activeCustomers', 'loyalThreshold'));
}
/**
* Laporan Pendapatan Rental
*/
public function revenue(Request $request): View
{
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
]);
$rentalId = Auth::user()->rentalCompany->id;
$dateFilter = $this->getDateFilter($request);
// Revenue untuk rental ini
$baseQuery = Booking::query()
->where('rental_company_id', $rentalId)
->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
$verifiedBookingCount = (clone $baseQuery)->count();
$grossRevenue = (float) ((clone $baseQuery)->sum('total_amount') ?? 0);
$commission = round($grossRevenue * ($this->commissionRate / 100), 2);
$netRevenue = round($grossRevenue - $commission, 2);
$rentalCompany = Auth::user()->rentalCompany;
return view('admin-rental.reports.revenue', compact(
'rentalCompany',
'verifiedBookingCount',
'grossRevenue',
'commission',
'netRevenue'
));
}
/**
* Helper: Get date filter array
*/
private function getDateFilter(Request $request): array
{
return [
'start' => $request->filled('start_date') ? $request->date('start_date')->startOfDay() : null,
'end' => $request->filled('end_date') ? $request->date('end_date')->endOfDay() : null,
];
}
/**
* Helper: Get base bookings query with filters
*/
private function getBaseBookingsQuery(int $rentalId, array $dateFilter): Builder
{
return Booking::query()
->where('rental_company_id', $rentalId)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\AdminRental;
use App\Http\Controllers\Controller;
use App\Models\RentalCompany;
use App\Models\Review;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class ReviewController extends Controller
{
public function index(): View|RedirectResponse
{
$rentalCompany = Auth::user()?->rentalCompany;
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company.');
}
$this->authorize('viewAny', Review::class);
$reviews = Review::query()
->with(['vehicle', 'customer', 'booking'])
->where('rental_company_id', $rentalCompany->id)
->latest('id')
->paginate(10);
return view('admin-rental.reviews.index', compact('reviews', 'rentalCompany'));
}
}

View File

@ -0,0 +1,316 @@
<?php
namespace App\Http\Controllers\AdminRental;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreVehicleRequest;
use App\Http\Requests\UpdateVehicleRequest;
use App\Models\Inventory;
use App\Models\RentalCompany;
use App\Models\Vehicle;
use App\Models\VehicleImage;
use App\Services\ActivityLogService;
use App\Services\FileUploadService;
use App\Services\SlugService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class VehicleController extends Controller
{
public function __construct(
private readonly SlugService $slugService,
private readonly FileUploadService $fileUploadService,
private readonly ActivityLogService $activityLogService,
) {
}
public function index(Request $request): View|RedirectResponse
{
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company. Silakan lengkapi data rental company terlebih dahulu.');
}
$vehicles = Vehicle::query()
->where('rental_company_id', $rentalCompany->id)
->when($request->filled('search'), function ($query) use ($request): void {
$search = $request->string('search')->toString();
$query->where(function ($subQuery) use ($search): void {
$subQuery->where('name', 'like', '%' . $search . '%')
->orWhere('brand', 'like', '%' . $search . '%')
->orWhere('category', 'like', '%' . $search . '%');
});
})
->withCount('images')
->with(['images'])
->latest()
->paginate(10)
->withQueryString();
return view('admin-rental.vehicles.index', compact('vehicles', 'rentalCompany'));
}
public function create(): View|RedirectResponse
{
$this->authorize('create', Vehicle::class);
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company. Silakan lengkapi data rental company terlebih dahulu.');
}
$vehicle = new Vehicle([
'status' => Vehicle::STATUS_ACTIVE,
]);
return view('admin-rental.vehicles.create', compact('vehicle', 'rentalCompany'));
}
public function store(StoreVehicleRequest $request): RedirectResponse
{
$this->authorize('create', Vehicle::class);
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company. Silakan lengkapi data rental company terlebih dahulu.');
}
$validated = $request->validated();
$slug = $this->slugService->generateUnique(Vehicle::class, 'slug', $validated['name']);
$mainImagePath = null;
try {
if ($request->hasFile('main_image')) {
$mainImagePath = $this->fileUploadService->storePublic($request->file('main_image'), 'vehicles/main');
}
} catch (\Throwable $exception) {
return back()->withInput()->with('error', 'Upload foto utama gagal. Silakan coba lagi dengan file yang valid.');
}
$vehicle = Vehicle::create([
'rental_company_id' => $rentalCompany->id,
'name' => $validated['name'],
'slug' => $slug,
'brand' => $validated['brand'],
'type' => $validated['type'],
'category' => $validated['category'],
'year' => $validated['year'],
'transmission' => $validated['transmission'],
'fuel_type' => $validated['fuel_type'],
'seat_capacity' => $validated['seat_capacity'],
'luggage_capacity' => $validated['luggage_capacity'] ?? null,
'color' => $validated['color'] ?? null,
'price_per_day' => $validated['price_per_day'],
'description' => $validated['description'] ?? null,
'terms_conditions' => $validated['terms_conditions'] ?? null,
'status' => $validated['status'],
'main_image' => $mainImagePath,
]);
Inventory::create([
'rental_company_id' => $rentalCompany->id,
'vehicle_id' => $vehicle->id,
'total' => 0,
'reserved' => 0,
'available' => 0,
]);
$this->storeGalleryImages($request, $vehicle);
$this->activityLogService->log(
action: 'vehicle.created',
description: 'Admin rental membuat kendaraan baru: ' . $vehicle->name,
targetType: 'vehicle',
targetId: $vehicle->id,
meta: ['slug' => $vehicle->slug]
);
return redirect()
->route('admin-rental.vehicles.index')
->with('success', 'Data kendaraan berhasil ditambahkan.');
}
public function edit(Vehicle $vehicle): View|RedirectResponse
{
$this->authorize('update', $vehicle);
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company. Silakan lengkapi data rental company terlebih dahulu.');
}
$vehicle->load('images');
return view('admin-rental.vehicles.edit', compact('vehicle', 'rentalCompany'));
}
public function update(UpdateVehicleRequest $request, Vehicle $vehicle): RedirectResponse
{
$this->authorize('update', $vehicle);
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company. Silakan lengkapi data rental company terlebih dahulu.');
}
$validated = $request->validated();
try {
if ($request->hasFile('main_image')) {
$this->fileUploadService->deletePublic($vehicle->main_image);
$vehicle->main_image = $this->fileUploadService->storePublic($request->file('main_image'), 'vehicles/main');
}
} catch (\Throwable $exception) {
return back()->withInput()->with('error', 'Upload foto utama gagal. Silakan coba lagi dengan file yang valid.');
}
$vehicle->fill([
'name' => $validated['name'],
'slug' => $this->slugService->generateUnique(Vehicle::class, 'slug', $validated['name'], $vehicle->id),
'brand' => $validated['brand'],
'type' => $validated['type'],
'category' => $validated['category'],
'year' => $validated['year'],
'transmission' => $validated['transmission'],
'fuel_type' => $validated['fuel_type'],
'seat_capacity' => $validated['seat_capacity'],
'luggage_capacity' => $validated['luggage_capacity'] ?? null,
'color' => $validated['color'] ?? null,
'price_per_day' => $validated['price_per_day'],
'description' => $validated['description'] ?? null,
'terms_conditions' => $validated['terms_conditions'] ?? null,
'status' => $validated['status'],
]);
$vehicle->save();
$this->storeGalleryImages($request, $vehicle);
$this->deleteSelectedGalleryImages($request, $vehicle);
$this->activityLogService->log(
action: 'vehicle.updated',
description: 'Admin rental memperbarui kendaraan: ' . $vehicle->name,
targetType: 'vehicle',
targetId: $vehicle->id,
meta: ['slug' => $vehicle->slug]
);
return redirect()
->route('admin-rental.vehicles.index')
->with('success', 'Data kendaraan berhasil diperbarui.');
}
public function destroy(Vehicle $vehicle): RedirectResponse
{
$this->authorize('delete', $vehicle);
$rentalCompany = $this->getRentalCompany();
if (!$rentalCompany) {
return redirect()
->route('admin-rental.dashboard')
->with('error', 'Akun admin rental ini belum memiliki rental company. Silakan lengkapi data rental company terlebih dahulu.');
}
if ($vehicle->bookings()->exists()) {
return redirect()
->route('admin-rental.vehicles.index')
->with('error', 'Kendaraan tidak dapat dihapus karena memiliki riwayat booking.');
}
$this->fileUploadService->deletePublic($vehicle->main_image);
foreach ($vehicle->images as $image) {
$this->fileUploadService->deletePublic($image->image_path);
$image->delete();
}
$vehicle->reviews()->delete();
$vehicle->inventory()->delete();
$deletedVehicleId = $vehicle->id;
$deletedVehicleName = $vehicle->name;
$vehicle->delete();
$this->activityLogService->log(
action: 'vehicle.deleted',
description: 'Admin rental menghapus kendaraan: ' . $deletedVehicleName,
targetType: 'vehicle',
targetId: $deletedVehicleId
);
return redirect()
->route('admin-rental.vehicles.index')
->with('success', 'Data kendaraan berhasil dihapus.');
}
public function destroyGalleryImage(VehicleImage $image): RedirectResponse
{
$image->load('vehicle');
$this->authorize('update', $image->vehicle);
$this->fileUploadService->deletePublic($image->image_path);
$image->delete();
return back()->with('success', 'Gambar galeri berhasil dihapus.');
}
private function getRentalCompany(): ?RentalCompany
{
return Auth::user()?->rentalCompany;
}
private function storeGalleryImages(StoreVehicleRequest|UpdateVehicleRequest $request, Vehicle $vehicle): void
{
if (!$request->hasFile('gallery_images')) {
return;
}
foreach ($request->file('gallery_images') as $imageFile) {
$imagePath = $this->fileUploadService->storePublic($imageFile, 'vehicles/gallery');
VehicleImage::create([
'vehicle_id' => $vehicle->id,
'image_path' => $imagePath,
'is_primary' => false,
]);
}
}
private function deleteSelectedGalleryImages(UpdateVehicleRequest $request, Vehicle $vehicle): void
{
$deleteIds = $request->input('delete_gallery_images', []);
if (empty($deleteIds)) {
return;
}
$galleryImages = VehicleImage::query()
->where('vehicle_id', $vehicle->id)
->whereIn('id', $deleteIds)
->get();
foreach ($galleryImages as $image) {
$this->fileUploadService->deletePublic($image->image_path);
$image->delete();
}
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class AuthController extends Controller
{
public function showLoginForm(Request $request): View|RedirectResponse
{
if (Auth::check()) {
return $this->redirectByRole((string) $request->user()->role, $request);
}
return view('auth.login');
}
public function showRegisterForm(Request $request): View|RedirectResponse
{
if (Auth::check()) {
return $this->redirectByRole((string) $request->user()->role, $request);
}
return view('auth.register');
}
public function login(Request $request): RedirectResponse
{
if (Auth::check()) {
return $this->redirectByRole((string) $request->user()->role, $request);
}
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
], [
'email.required' => 'Email wajib diisi.',
'email.email' => 'Format email tidak valid.',
'password.required' => 'Password wajib diisi.',
]);
if (!Auth::attempt($credentials, $request->boolean('remember'))) {
return back()
->withErrors([
'email' => 'Email atau password salah.',
])
->onlyInput('email');
}
$request->session()->regenerate();
$user = $request->user();
return $this->redirectByRole((string) $user->role, $request);
}
public function register(Request $request): RedirectResponse
{
if (Auth::check()) {
return $this->redirectByRole((string) $request->user()->role, $request);
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email:rfc,dns', 'max:255', 'unique:users,email'],
'phone' => ['required', 'string', 'max:20'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'terms' => ['accepted'],
], [
'name.required' => 'Nama lengkap wajib diisi.',
'name.max' => 'Nama maksimal 100 karakter.',
'email.required' => 'Email wajib diisi.',
'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah terdaftar. Silakan login.',
'phone.required' => 'Nomor HP wajib diisi.',
'phone.max' => 'Nomor HP maksimal 20 karakter.',
'password.required' => 'Password wajib diisi.',
'password.min' => 'Password minimal 8 karakter.',
'password.confirmed' => 'Konfirmasi password tidak cocok.',
'terms.accepted' => 'Anda harus menyetujui syarat dan ketentuan.',
]);
User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'phone' => $validated['phone'],
'password' => Hash::make($validated['password']),
'role' => 'customer',
]);
return redirect()
->route('login')
->with('success', 'Pendaftaran berhasil. Silakan login sebagai customer.');
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('home');
}
private function redirectByRole(string $role, Request $request): RedirectResponse
{
if ($role === 'super_admin') {
return redirect()->route('super-admin.dashboard');
}
if ($role === 'admin_rental') {
return redirect()->route('admin-rental.dashboard');
}
if ($role === 'customer') {
return redirect()->route('home');
}
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->withErrors([
'email' => 'Role akun tidak dikenali. Hubungi administrator.',
]);
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}

View File

@ -0,0 +1,228 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreBookingRequest;
use App\Models\Booking;
use App\Models\RentalCompany;
use App\Models\Payment;
use App\Models\Vehicle;
use App\Models\Inventory;
use App\Services\ActivityLogService;
use App\Services\NotificationService;
use App\Services\PromoService;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class BookingController extends Controller
{
public function __construct(
private readonly PromoService $promoService,
private readonly NotificationService $notificationService,
private readonly ActivityLogService $activityLogService,
) {
}
public function create(Vehicle $vehicle): View
{
$this->authorize('create', Booking::class);
$vehicle->load(['rentalCompany', 'images', 'primaryImage']);
$this->ensureVehicleCanBeBooked($vehicle);
$customer = Auth::user();
$pickupDate = now()->addDay()->toDateString();
$returnDate = now()->addDays(2)->toDateString();
$durationDays = Carbon::parse($pickupDate)->diffInDays(Carbon::parse($returnDate)) + 1;
$subtotal = $durationDays * (float) $vehicle->price_per_day;
$availablePromos = $this->promoService->getVisiblePromosForBooking($vehicle->rental_company_id, (int) Auth::id(), $subtotal);
return view('booking.index', compact('vehicle', 'customer', 'pickupDate', 'returnDate', 'durationDays', 'subtotal', 'availablePromos'));
}
public function store(StoreBookingRequest $request, Vehicle $vehicle): RedirectResponse
{
$this->authorize('create', Booking::class);
$vehicle->load('rentalCompany');
$this->ensureVehicleCanBeBooked($vehicle);
$validated = $request->validated();
$pickupDate = Carbon::parse($validated['pickup_date'])->startOfDay();
$returnDate = Carbon::parse($validated['return_date'])->startOfDay();
$durationDays = $pickupDate->diffInDays($returnDate) + 1;
$subtotal = $durationDays * (float) $vehicle->price_per_day;
$withDriver = $request->boolean('with_driver');
$additionalCost = 0;
$promoCode = $validated['promo_code'] ?? null;
if ($this->isVehicleBookedForDateRange($vehicle->id, $pickupDate, $returnDate)) {
return back()
->withInput()
->withErrors([
'pickup_date' => 'Kendaraan tidak tersedia pada rentang tanggal tersebut.',
]);
}
try {
$booking = DB::transaction(function () use ($validated, $vehicle, $pickupDate, $returnDate, $durationDays, $subtotal, $additionalCost, $withDriver, $promoCode): Booking {
$promoResult = $this->promoService->resolvePromoForBooking(
$promoCode,
(int) $vehicle->rental_company_id,
(int) Auth::id(),
$subtotal,
true
);
$promo = $promoResult['promo'];
$discountAmount = (float) $promoResult['discount_amount'];
$totalAmount = max(0, $subtotal - $discountAmount + $additionalCost);
$booking = Booking::create([
'booking_code' => $this->generateBookingCode(),
'customer_id' => Auth::id(),
'rental_company_id' => $vehicle->rental_company_id,
'vehicle_id' => $vehicle->id,
'promo_id' => $promo?->id,
'pickup_date' => $pickupDate->toDateString(),
'return_date' => $returnDate->toDateString(),
'pickup_time' => $validated['pickup_time'] ?? null,
'pickup_location' => $validated['pickup_location'],
'return_location' => $validated['return_location'] ?? null,
'duration_days' => $durationDays,
'with_driver' => $withDriver,
'customer_name' => $validated['customer_name'],
'customer_email' => $validated['customer_email'],
'customer_phone' => $validated['customer_phone'],
'customer_address' => $validated['customer_address'],
'identity_number' => $validated['identity_number'],
'driver_license_number' => $validated['driver_license_number'] ?? null,
'note' => $validated['note'] ?? null,
'subtotal' => $subtotal,
'discount_amount' => $discountAmount,
'additional_cost' => $additionalCost,
'total_amount' => $totalAmount,
'booking_status' => Booking::BOOKING_WAITING_PAYMENT,
'payment_status' => Booking::PAYMENT_UNPAID,
]);
if ($promo) {
$promo->increment('used_count');
}
Payment::create([
'booking_id' => $booking->id,
'payment_method' => 'manual_transfer',
'amount' => $totalAmount,
'proof_payment' => null,
'paid_at' => null,
'verified_by' => null,
'verified_at' => null,
'payment_status' => Payment::STATUS_UNPAID,
'rejection_note' => null,
]);
return $booking;
});
} catch (ValidationException $exception) {
return back()->withInput()->withErrors($exception->errors());
}
$this->notificationService->notifyUser(
userId: (int) $booking->customer_id,
title: 'Booking Berhasil Dibuat',
message: 'Booking ' . $booking->booking_code . ' berhasil dibuat. Silakan lanjutkan pembayaran.',
type: 'success',
url: route('customer.bookings.show', $booking),
referenceType: 'booking',
referenceId: $booking->id,
);
$rentalAdminId = $booking->rentalCompany?->user_id;
if ($rentalAdminId) {
$this->notificationService->notifyUser(
userId: (int) $rentalAdminId,
title: 'Booking Baru Masuk',
message: 'Ada booking baru ' . $booking->booking_code . ' dari customer.',
type: 'info',
url: route('admin-rental.bookings.show', $booking),
referenceType: 'booking',
referenceId: $booking->id,
);
}
$this->activityLogService->log(
action: 'booking.created',
description: 'Customer membuat booking baru: ' . $booking->booking_code,
targetType: 'booking',
targetId: $booking->id,
meta: ['vehicle_id' => $booking->vehicle_id, 'total_amount' => $booking->total_amount]
);
return redirect()
->route('pembayaran.show', $booking)
->with('success', 'Booking berhasil dibuat. Silakan lanjutkan ke langkah pembayaran.');
}
private function ensureVehicleCanBeBooked(Vehicle $vehicle): void
{
if (
$vehicle->status !== Vehicle::STATUS_ACTIVE ||
!$vehicle->rentalCompany ||
$vehicle->rentalCompany->status_verification !== RentalCompany::STATUS_APPROVED
) {
abort(404);
}
}
private function generateBookingCode(): string
{
do {
$bookingCode = 'BK-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6));
} while (Booking::where('booking_code', $bookingCode)->exists());
return $bookingCode;
}
private function isVehicleBookedForDateRange(int $vehicleId, Carbon $pickupDate, Carbon $returnDate): bool
{
$blockingStatuses = [
Booking::BOOKING_WAITING_PAYMENT,
Booking::BOOKING_WAITING_VERIFICATION,
Booking::BOOKING_CONFIRMED,
Booking::BOOKING_ONGOING,
Booking::BOOKING_PENDING,
];
$overlappingCount = Booking::query()
->where('vehicle_id', $vehicleId)
->whereIn('booking_status', $blockingStatuses)
->where(function ($query) use ($pickupDate, $returnDate): void {
$query->whereBetween('pickup_date', [$pickupDate->toDateString(), $returnDate->toDateString()])
->orWhereBetween('return_date', [$pickupDate->toDateString(), $returnDate->toDateString()])
->orWhere(function ($nestedQuery) use ($pickupDate, $returnDate): void {
$nestedQuery->where('pickup_date', '<=', $pickupDate->toDateString())
->where('return_date', '>=', $returnDate->toDateString());
});
})
->count();
$inventory = Inventory::where('vehicle_id', $vehicleId)->first();
if (! $inventory) {
// Fallback to single-unit behavior when inventory not tracked
return $overlappingCount > 0;
}
$availableUnits = $inventory->available ?? ($inventory->total - ($inventory->reserved ?? 0));
return $overlappingCount >= max(1, (int) $availableUnits);
}
}

View File

@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\Review;
use App\Models\Vehicle;
use Illuminate\Http\Request;
use Illuminate\View\View;
class KatalogController extends Controller
{
public function index(Request $request): View
{
$validated = $request->validate([
'q' => ['nullable', 'string', 'max:100'],
'lokasi' => ['nullable', 'string', 'max:100'],
'tanggal_sewa' => ['nullable', 'date'],
'tanggal_selesai' => ['nullable', 'date', 'after:tanggal_sewa'],
'category' => ['nullable', 'string', 'max:100'],
'transmission' => ['nullable', 'string', 'max:50'],
'fuel_type' => ['nullable', 'string', 'max:50'],
'seat_capacity' => ['nullable', 'integer', 'min:1', 'max:99'],
'price_min' => ['nullable', 'numeric', 'min:0'],
'price_max' => ['nullable', 'numeric', 'min:0'],
'sort' => ['nullable', 'in:newest,price_low,price_high'],
]);
$query = Vehicle::query()
->with(['rentalCompany', 'images'])
->visibleToCustomers();
if (!empty($validated['q'])) {
$search = $validated['q'];
$query->where(function ($subQuery) use ($search): void {
$subQuery->where('name', 'like', '%' . $search . '%')
->orWhere('brand', 'like', '%' . $search . '%')
->orWhere('category', 'like', '%' . $search . '%');
});
}
if (!empty($validated['lokasi'])) {
$query->whereHas('rentalCompany', function ($q) use ($validated): void {
$q->where('city', $validated['lokasi']);
});
}
if (!empty($validated['tanggal_sewa']) && !empty($validated['tanggal_selesai'])) {
$mulai = $validated['tanggal_sewa'];
$selesai = $validated['tanggal_selesai'];
// Hanya tampilkan kendaraan yang tidak punya booking aktif di rentang tanggal tersebut
$query->whereDoesntHave('bookings', function ($q) use ($mulai, $selesai): void {
$q->whereNotIn('booking_status', ['cancelled'])
->where('pickup_date', '<=', $selesai)
->where('return_date', '>=', $mulai);
});
}
if (!empty($validated['category'])) {
$query->where('category', $validated['category']);
}
if (!empty($validated['transmission'])) {
$query->where('transmission', $validated['transmission']);
}
if (!empty($validated['fuel_type'])) {
$query->where('fuel_type', $validated['fuel_type']);
}
if (!empty($validated['seat_capacity'])) {
$query->where('seat_capacity', $validated['seat_capacity']);
}
if (!empty($validated['price_min'])) {
$query->where('price_per_day', '>=', $validated['price_min']);
}
if (!empty($validated['price_max'])) {
$query->where('price_per_day', '<=', $validated['price_max']);
}
$sort = $validated['sort'] ?? 'newest';
if ($sort === 'price_low') {
$query->orderBy('price_per_day', 'asc');
} elseif ($sort === 'price_high') {
$query->orderBy('price_per_day', 'desc');
} else {
$query->latest();
}
$vehicles = $query->paginate(9)->withQueryString();
$filterSource = Vehicle::query()->visibleToCustomers();
$categories = (clone $filterSource)->distinct()->orderBy('category')->pluck('category');
$transmissions = (clone $filterSource)->distinct()->orderBy('transmission')->pluck('transmission');
$fuelTypes = (clone $filterSource)->distinct()->orderBy('fuel_type')->pluck('fuel_type');
$seatCapacities = (clone $filterSource)->distinct()->orderBy('seat_capacity')->pluck('seat_capacity');
$summaryCount = $vehicles->total();
$tanggalSewa = $validated['tanggal_sewa'] ?? null;
$tanggalSelesai = $validated['tanggal_selesai'] ?? null;
return view('katalog.index', compact(
'vehicles',
'categories',
'transmissions',
'fuelTypes',
'seatCapacities',
'summaryCount',
'sort',
'tanggalSewa',
'tanggalSelesai'
));
}
public function show(Vehicle $vehicle): View
{
$vehicle->load(['rentalCompany', 'images']);
if ($vehicle->status !== Vehicle::STATUS_ACTIVE || !$vehicle->rentalCompany || $vehicle->rentalCompany->status_verification !== 'approved') {
abort(404);
}
$baseReviewQuery = Review::query()
->where('vehicle_id', $vehicle->id)
->whereHas('booking', function ($query): void {
$query->where('booking_status', Booking::BOOKING_COMPLETED);
});
$averageRating = (clone $baseReviewQuery)->avg('rating');
$totalReviews = (clone $baseReviewQuery)->count();
$ratingBreakdown = [];
for ($star = 5; $star >= 1; $star--) {
$ratingBreakdown[$star] = (clone $baseReviewQuery)->where('rating', $star)->count();
}
$reviews = (clone $baseReviewQuery)
->with('customer')
->latest('id')
->take(8)
->get();
$averageRating = $averageRating ? round((float) $averageRating, 1) : 0;
return view('detail-mobil.index', compact('vehicle', 'reviews', 'averageRating', 'totalReviews', 'ratingBreakdown'));
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class MyBookingController extends Controller
{
public function index(Request $request): View
{
$this->authorize('viewAny', Booking::class);
$request->validate([
'booking_status' => ['nullable', 'in:waiting_payment,waiting_verification,confirmed,ongoing,completed,cancelled'],
'payment_status' => ['nullable', 'in:unpaid,uploaded,verified,rejected'],
'search' => ['nullable', 'string', 'max:100'],
]);
$bookings = Booking::query()
->with(['vehicle.rentalCompany', 'vehicle.primaryImage', 'payment', 'review'])
->where('customer_id', Auth::id())
->when($request->filled('booking_status'), function ($query) use ($request): void {
$query->where('booking_status', $request->string('booking_status')->toString());
})
->when($request->filled('payment_status'), function ($query) use ($request): void {
$query->where('payment_status', $request->string('payment_status')->toString());
})
->when($request->filled('search'), function ($query) use ($request): void {
$search = $request->string('search')->toString();
$query->where(function ($subQuery) use ($search): void {
$subQuery->where('booking_code', 'like', '%' . $search . '%')
->orWhereHas('vehicle', function ($vehicleQuery) use ($search): void {
$vehicleQuery->where('name', 'like', '%' . $search . '%');
});
});
})
->latest('id')
->paginate(10)
->withQueryString();
$bookingStatusOptions = Booking::statusOptions();
$paymentStatusOptions = Booking::paymentStatusOptions();
return view('customer.bookings.index', compact('bookings', 'bookingStatusOptions', 'paymentStatusOptions'));
}
public function show(Booking $booking): View
{
$this->authorize('view', $booking);
$booking->load(['vehicle.rentalCompany', 'vehicle.primaryImage', 'customer', 'payment', 'review']);
return view('customer.bookings.show', compact('booking'));
}
private function ensureBookingOwnership(Booking $booking): void
{
if ((int) $booking->customer_id !== (int) Auth::id()) {
abort(404);
}
}
}

View File

@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Http\Controllers\Controller;
use App\Http\Requests\UploadPaymentProofRequest;
use App\Models\Booking;
use App\Models\Payment;
use App\Services\ActivityLogService;
use App\Services\FileUploadService;
use App\Services\NotificationService;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class PaymentController extends Controller
{
public function __construct(
private readonly FileUploadService $fileUploadService,
private readonly NotificationService $notificationService,
private readonly ActivityLogService $activityLogService,
) {
}
public function show(Booking $booking): View
{
$this->authorize('view', $booking);
$this->ensureCustomerOwnsBooking($booking);
$booking->load(['customer', 'vehicle.rentalCompany', 'vehicle.primaryImage', 'payment']);
$payment = $this->ensurePaymentExists($booking);
$paymentMethods = config('payment_methods');
$selectedMethod = old('payment_method', $payment->payment_method);
return view('pembayaran.index', compact('booking', 'payment', 'paymentMethods', 'selectedMethod'));
}
public function uploadProof(UploadPaymentProofRequest $request, Booking $booking): RedirectResponse
{
$this->authorize('update', $booking);
$this->ensureCustomerOwnsBooking($booking);
$booking->load(['payment', 'rentalCompany']);
$payment = $this->ensurePaymentExists($booking);
if ($payment->payment_status === Payment::STATUS_VERIFIED || $booking->payment_status === Booking::PAYMENT_VERIFIED) {
return back()->with('error', 'Pembayaran sudah diverifikasi dan tidak dapat diubah lagi.');
}
$validated = $request->validated();
$paymentMethod = $validated['payment_method'];
$oldProofPath = $payment->proof_payment;
try {
$proofPath = $this->fileUploadService->storePublic($request->file('proof_payment'), 'payments/proofs');
} catch (\Throwable $exception) {
return back()->withInput()->with('error', 'Upload bukti pembayaran gagal. Pastikan file valid dan coba lagi.');
}
DB::transaction(function () use ($booking, $payment, $paymentMethod, $proofPath): void {
$payment->update([
'payment_method' => $paymentMethod,
'proof_payment' => $proofPath,
'paid_at' => now(),
'payment_status' => Payment::STATUS_UPLOADED,
'rejection_note' => null,
]);
$booking->update([
'payment_status' => Booking::PAYMENT_UPLOADED,
'booking_status' => Booking::BOOKING_WAITING_VERIFICATION,
]);
});
if ($oldProofPath && $oldProofPath !== $proofPath) {
$this->fileUploadService->deletePublic($oldProofPath);
}
$rentalAdminId = $booking->rentalCompany?->user_id;
if ($rentalAdminId) {
$this->notificationService->notifyUser(
userId: (int) $rentalAdminId,
title: 'Bukti Pembayaran Baru',
message: 'Booking ' . $booking->booking_code . ' mengunggah bukti pembayaran baru.',
type: 'info',
url: route('admin-rental.payments.show', $booking),
referenceType: 'payment',
referenceId: (int) $payment->id,
);
}
$this->activityLogService->log(
action: 'payment.uploaded',
description: 'Customer upload bukti pembayaran untuk booking: ' . $booking->booking_code,
targetType: 'payment',
targetId: (int) $payment->id,
meta: ['booking_id' => $booking->id, 'payment_method' => $paymentMethod]
);
return redirect()
->route('pembayaran.show', $booking)
->with('success', 'Bukti pembayaran berhasil diupload dan menunggu verifikasi admin rental.');
}
public function invoice(Booking $booking): View
{
$this->authorize('view', $booking);
$this->ensureCustomerOwnsBooking($booking);
$booking->load(['customer', 'vehicle.rentalCompany', 'payment']);
$payment = $this->ensurePaymentExists($booking);
return view('pembayaran.print', [
'booking' => $booking,
'payment' => $payment,
'paymentMethods' => config('payment_methods'),
'selectedMethod' => $payment->payment_method,
'documentTitle' => 'Invoice',
]);
}
public function receipt(Booking $booking): View
{
$this->authorize('view', $booking);
$this->ensureCustomerOwnsBooking($booking);
$booking->load(['customer', 'vehicle.rentalCompany', 'payment']);
$payment = $this->ensurePaymentExists($booking);
return view('pembayaran.print', [
'booking' => $booking,
'payment' => $payment,
'paymentMethods' => config('payment_methods'),
'selectedMethod' => $payment->payment_method,
'documentTitle' => 'Bukti Transaksi',
]);
}
private function ensureCustomerOwnsBooking(Booking $booking): void
{
if ((int) $booking->customer_id !== (int) Auth::id()) {
abort(404);
}
}
private function ensurePaymentExists(Booking $booking): Payment
{
if ($booking->payment) {
return $booking->payment;
}
return $booking->payment()->create([
'payment_method' => 'manual_transfer',
'amount' => $booking->total_amount,
'payment_status' => Payment::STATUS_UNPAID,
]);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Customer;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreReviewRequest;
use App\Models\Booking;
use App\Models\Review;
use App\Services\ActivityLogService;
use App\Services\NotificationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class ReviewController extends Controller
{
public function __construct(
private readonly NotificationService $notificationService,
private readonly ActivityLogService $activityLogService,
) {
}
public function create(Booking $booking): View|RedirectResponse
{
if (!$this->can('create', [Review::class, $booking])) {
abort(403);
}
if ($booking->booking_status !== Booking::BOOKING_COMPLETED) {
return redirect()
->route('customer.bookings.show', $booking)
->with('error', 'Ulasan hanya bisa dibuat setelah booking selesai.');
}
if ($booking->review()->exists()) {
return redirect()
->route('customer.bookings.show', $booking)
->with('error', 'Booking ini sudah memiliki ulasan.');
}
$booking->load(['vehicle.rentalCompany']);
return view('customer.reviews.create', compact('booking'));
}
public function store(StoreReviewRequest $request, Booking $booking): RedirectResponse
{
if (!$this->can('create', [Review::class, $booking])) {
abort(403);
}
if ($booking->booking_status !== Booking::BOOKING_COMPLETED) {
return redirect()
->route('customer.bookings.show', $booking)
->with('error', 'Ulasan hanya bisa dibuat setelah booking selesai.');
}
if ($booking->review()->exists()) {
return redirect()
->route('customer.bookings.show', $booking)
->with('error', 'Booking ini sudah memiliki ulasan.');
}
$validated = $request->validated();
$review = Review::create([
'booking_id' => $booking->id,
'customer_id' => Auth::id(),
'vehicle_id' => $booking->vehicle_id,
'rental_company_id' => $booking->rental_company_id,
'rating' => $validated['rating'],
'review' => $validated['review'] ?? null,
]);
$rentalAdminId = $booking->rentalCompany?->user_id;
if ($rentalAdminId) {
$this->notificationService->notifyUser(
userId: (int) $rentalAdminId,
title: 'Ulasan Baru Customer',
message: 'Booking ' . $booking->booking_code . ' menerima ulasan baru.',
type: 'info',
url: route('admin-rental.reviews.index'),
referenceType: 'review',
referenceId: $review->id,
);
}
$this->activityLogService->log(
action: 'review.created',
description: 'Customer mengirim review untuk booking: ' . $booking->booking_code,
targetType: 'review',
targetId: $review->id,
meta: ['rating' => $review->rating]
);
return redirect()
->route('customer.bookings.show', $booking)
->with('success', 'Ulasan berhasil dikirim. Terima kasih atas feedback Anda.');
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Models\RentalCompany;
use App\Models\Vehicle;
use App\Models\Promo;
use App\Models\Review;
class HomeController extends Controller
{
public function index()
{
// Ambil kendaraan unggulan (featured vehicles) - limit 4
$featuredVehicles = Vehicle::where('status', 'active')
->with(['rentalCompany', 'images'])
->withAvg('reviews', 'rating')
->withCount('reviews')
->take(4)
->get();
// Ambil promo aktif (active promotions)
$promos = Promo::where('status', 'active')
->where('end_date', '>=', now())
->with('rentalCompany')
->get();
// Ambil testimoni (reviews) - limit 3 dengan rating tertinggi
$testimonials = Review::with(['customer', 'vehicle'])
->orderByDesc('rating')
->take(3)
->get();
// Ambil kategori kendaraan untuk filter
$vehicleCategories = Vehicle::where('status', 'active')
->distinct()
->pluck('category')
->filter()
->values();
// Ambil daftar kota dari rental company yang sudah approved dan punya kendaraan aktif
$lokasiOptions = RentalCompany::where('status_verification', 'approved')
->whereHas('vehicles', fn($q) => $q->where('status', 'active'))
->distinct()
->orderBy('city')
->pluck('city')
->filter()
->values();
return view('home.index', compact(
'featuredVehicles',
'promos',
'testimonials',
'vehicleCategories',
'lokasiOptions'
));
}
public function search()
{
$lokasi = request('lokasi');
$tanggal_sewa = request('tanggal_sewa');
$tanggal_selesai = request('tanggal_selesai');
$jenis_kendaraan = request('jenis_kendaraan');
return redirect()->route('katalog.index', [
'lokasi' => $lokasi,
'tanggal_sewa' => $tanggal_sewa,
'tanggal_selesai' => $tanggal_selesai,
'category' => $jenis_kendaraan,
]);
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Models\UserNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
$request->validate([
'status' => ['nullable', 'in:all,unread,read'],
]);
$notifications = UserNotification::query()
->where('user_id', (int) $request->user()->id)
->when($request->string('status')->toString() === 'unread', function ($query): void {
$query->whereNull('read_at');
})
->when($request->string('status')->toString() === 'read', function ($query): void {
$query->whereNotNull('read_at');
})
->latest('id')
->paginate(12)
->withQueryString();
$view = $request->user()->role === 'customer'
? 'customer.notifications.index'
: 'admin-rental.notifications.index';
return view($view, compact('notifications'));
}
public function read(Request $request, UserNotification $notification): RedirectResponse
{
if ((int) $notification->user_id !== (int) $request->user()->id) {
abort(403);
}
$notification->markAsRead();
return back()->with('success', 'Notifikasi ditandai sudah dibaca.');
}
public function readAll(Request $request): RedirectResponse
{
UserNotification::query()
->where('user_id', (int) $request->user()->id)
->whereNull('read_at')
->update(['read_at' => now()]);
return back()->with('success', 'Semua notifikasi ditandai sudah dibaca.');
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers;
use App\Models\RentalCompany;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
class RentalRegistrationController extends Controller
{
/**
* Show the form for registering a rental company.
*/
public function show()
{
return view('auth.register-rental');
}
/**
* Store the rental company registration.
*/
public function store(Request $request)
{
// Validasi input
$validated = $request->validate([
'company_name' => ['required', 'string', 'max:100', 'unique:rental_companies,company_name'],
'email' => ['required', 'email', 'unique:rental_companies,email', 'unique:users,email'],
'phone' => ['required', 'string', 'max:20'],
'address' => ['required', 'string', 'max:255'],
'city' => ['required', 'string', 'max:100'],
'document' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:5120'], // Max 5MB
'password' => ['required', 'string', 'min:8', 'confirmed'],
'terms' => ['accepted'],
], [
'company_name.required' => 'Nama perusahaan harus diisi.',
'company_name.unique' => 'Nama perusahaan sudah terdaftar.',
'email.required' => 'Email harus diisi.',
'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah terdaftar.',
'phone.required' => 'Nomor telepon harus diisi.',
'address.required' => 'Alamat harus diisi.',
'city.required' => 'Kota harus diisi.',
'document.required' => 'Dokumen SIUP/Izin Usaha harus diunggah.',
'document.mimes' => 'Format dokumen harus PDF, JPG, JPEG, atau PNG.',
'document.max' => 'Ukuran dokumen maksimal 5MB.',
'password.required' => 'Password harus diisi.',
'password.min' => 'Password minimal 8 karakter.',
'password.confirmed' => 'Password konfirmasi tidak cocok.',
'terms.accepted' => 'Anda harus menyetujui syarat dan ketentuan.',
]);
// Upload dokumen
$documentPath = null;
if ($request->hasFile('document')) {
$documentPath = $request->file('document')->store('rental-documents', 'public');
}
// Buat user akun untuk admin rental dengan status inactive
$user = User::create([
'name' => $validated['company_name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'phone' => $validated['phone'],
'role' => 'admin_rental',
]);
// Buat rental company dengan status pending
$rentalCompany = RentalCompany::create([
'user_id' => $user->id,
'company_name' => $validated['company_name'],
'email' => $validated['email'],
'phone' => $validated['phone'],
'address' => $validated['address'],
'city' => $validated['city'],
'document' => $documentPath,
'status_verification' => 'pending',
]);
// TODO: Kirim notifikasi ke super admin
// Notify super admin tentang ada rental company baru yang pending approval
// dispatch(new NotifyNewRentalRegistration($rentalCompany));
return redirect()->route('rental.register.success', ['company' => $rentalCompany->id])
->with('success', 'Pendaftaran rental company berhasil! Mohon tunggu verifikasi dari super admin.');
}
/**
* Show success page after registration.
*/
public function success($company)
{
$rentalCompany = RentalCompany::find($company);
if (!$rentalCompany) {
abort(404);
}
return view('rental-registration.success', compact('rentalCompany'));
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\SuperAdmin;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ActivityLogController extends Controller
{
public function index(Request $request): View
{
$request->validate([
'action' => ['nullable', 'string', 'max:100'],
'user_id' => ['nullable', 'integer', 'exists:users,id'],
]);
$activityLogs = ActivityLog::query()
->with('user')
->when($request->filled('action'), function ($query) use ($request): void {
$query->where('action', 'like', '%' . $request->string('action')->toString() . '%');
})
->when($request->filled('user_id'), function ($query) use ($request): void {
$query->where('user_id', (int) $request->input('user_id'));
})
->orderByDesc('id')
->paginate(12)
->withQueryString();
$userOptions = User::query()->orderBy('name')->get(['id', 'name', 'email', 'role']);
return view('super-admin.activity-logs.index', compact('activityLogs', 'userOptions'));
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\SuperAdmin;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\RentalCompany;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CommissionController extends Controller
{
public function index(Request $request): View
{
$request->validate([
'rental_company_id' => ['nullable', 'integer', 'exists:rental_companies,id'],
'booking_status' => ['nullable', 'in:confirmed,ongoing,completed'],
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
]);
$commissionRate = (float) config('platform.commission_percentage', 10);
$commissionBaseQuery = Booking::query()
->with(['rentalCompany', 'customer'])
->where('payment_status', Booking::PAYMENT_VERIFIED)
->whereIn('booking_status', [
Booking::BOOKING_CONFIRMED,
Booking::BOOKING_ONGOING,
Booking::BOOKING_COMPLETED,
])
->when($request->filled('rental_company_id'), function (Builder $query) use ($request): void {
$query->where('rental_company_id', (int) $request->input('rental_company_id'));
})
->when($request->filled('booking_status'), function (Builder $query) use ($request): void {
$statusMap = [
'confirmed' => Booking::BOOKING_CONFIRMED,
'ongoing' => Booking::BOOKING_ONGOING,
'completed' => Booking::BOOKING_COMPLETED,
];
$query->where('booking_status', $statusMap[$request->string('booking_status')->toString()]);
})
->when($request->filled('start_date'), function (Builder $query) use ($request): void {
$query->whereDate('created_at', '>=', $request->string('start_date')->toString());
})
->when($request->filled('end_date'), function (Builder $query) use ($request): void {
$query->whereDate('created_at', '<=', $request->string('end_date')->toString());
});
$totalTransaction = (float) ((clone $commissionBaseQuery)->sum('total_amount') ?? 0);
$totalCommission = round($totalTransaction * ($commissionRate / 100), 2);
$bookings = (clone $commissionBaseQuery)
->latest('id')
->paginate(15)
->withQueryString();
$rentalOptions = RentalCompany::query()
->orderBy('company_name')
->get(['id', 'company_name']);
$bookingStatuses = [
'confirmed' => 'Dikonfirmasi',
'ongoing' => 'Sedang Berlangsung',
'completed' => 'Selesai',
];
return view('super-admin.commissions.index', compact(
'bookings',
'commissionRate',
'totalTransaction',
'totalCommission',
'rentalOptions',
'bookingStatuses'
));
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\SuperAdmin;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\RentalCompany;
use App\Models\User;
use App\Models\Vehicle;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(): View
{
$commissionRate = (float) config('platform.commission_percentage', 10);
$validCommissionBase = Booking::query()
->where('payment_status', Booking::PAYMENT_VERIFIED)
->whereIn('booking_status', [
Booking::BOOKING_CONFIRMED,
Booking::BOOKING_ONGOING,
Booking::BOOKING_COMPLETED,
]);
$totalRevenue = (float) ((clone $validCommissionBase)->sum('total_amount') ?? 0);
$totalCommission = round($totalRevenue * ($commissionRate / 100), 2);
$totalRental = (int) RentalCompany::count();
$mitraRental = (int) RentalCompany::approved()->count();
$totalCustomers = (int) User::where('role', 'customer')->count();
$totalVehicles = (int) Vehicle::count();
$verifiedPayments = (int) Booking::where('payment_status', Booking::PAYMENT_VERIFIED)->count();
$pendingCount = (int) RentalCompany::where('status_verification', RentalCompany::STATUS_PENDING)->count();
$monthlyBaseQuery = Booking::query();
$driverName = DB::connection()->getDriverName();
$monthKeyExpression = $driverName === 'sqlite'
? "strftime('%Y-%m', created_at)"
: "DATE_FORMAT(created_at, '%Y-%m')";
$monthlyBookingCounts = $monthlyBaseQuery
->selectRaw($monthKeyExpression . ' as month_key, COUNT(*) as total')
->groupByRaw($monthKeyExpression)
->orderByRaw($monthKeyExpression)
->pluck('total', 'month_key');
$monthlyBookings = collect(range(11, 0))
->map(function (int $offset) use ($monthlyBookingCounts): array {
$month = Carbon::now()->startOfMonth()->subMonths($offset);
$monthKey = $month->format('Y-m');
return [
'label' => $month->format('M Y'),
'value' => (int) ($monthlyBookingCounts[$monthKey] ?? 0),
];
})
->values();
$summary = [
['label' => 'Mitra Rental', 'value' => $mitraRental, 'hint' => 'Rental approved / aktif', 'icon' => 'bi-buildings'],
['label' => 'Customer', 'value' => $totalCustomers, 'hint' => 'Akun customer terdaftar', 'icon' => 'bi-people'],
['label' => 'Kendaraan', 'value' => $totalVehicles, 'hint' => 'Total armada aktif', 'icon' => 'bi-car-front'],
['label' => 'Total Rental', 'value' => $totalRental, 'hint' => 'Seluruh rental terdaftar', 'icon' => 'bi-shop'],
['label' => 'Revenue', 'value' => 'Rp ' . number_format($totalRevenue, 0, ',', '.'), 'hint' => 'Transaksi verified', 'icon' => 'bi-cash-stack'],
['label' => 'Payment Verified', 'value' => $verifiedPayments, 'hint' => 'Pembayaran terverifikasi', 'icon' => 'bi-patch-check'],
['label' => 'Pending', 'value' => $pendingCount, 'hint' => 'Rental menunggu verifikasi', 'icon' => 'bi-hourglass-split'],
['label' => 'Komisi', 'value' => 'Rp ' . number_format($totalCommission, 0, ',', '.'), 'hint' => 'Estimasi komisi platform', 'icon' => 'bi-percent'],
];
$quickLinks = [
['label' => 'Verifikasi Rental', 'route' => route('super-admin.rentals.index'), 'icon' => 'bi-shield-check', 'hint' => 'Tinjau pengajuan partner'],
['label' => 'Semua User', 'route' => route('super-admin.users.index'), 'icon' => 'bi-people', 'hint' => 'Kelola akun sistem'],
['label' => 'Laporan', 'route' => route('super-admin.reports.index'), 'icon' => 'bi-graph-up-arrow', 'hint' => 'Monitoring performa'],
['label' => 'Komisi', 'route' => route('super-admin.reports.commissions'), 'icon' => 'bi-receipt', 'hint' => 'Detail komisi platform'],
];
return view('super-admin.dashboard', compact(
'summary',
'quickLinks',
'commissionRate',
'totalRevenue',
'totalCommission',
'totalRental',
'mitraRental',
'totalCustomers',
'totalVehicles',
'verifiedPayments',
'pendingCount',
'monthlyBookings'
));
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\SuperAdmin;
use App\Http\Controllers\Controller;
use App\Models\Inventory;
use App\Models\RentalCompany;
use Illuminate\Http\Request;
use Illuminate\View\View;
class InventoryController extends Controller
{
/**
* Display a listing of inventories across rentals (view-only).
*/
public function index(Request $request): View
{
$query = Inventory::with(['vehicle', 'rentalCompany', 'lastCheckedBy']);
if ($request->filled('rental_company_id')) {
$query->where('rental_company_id', $request->integer('rental_company_id'));
}
if ($request->filled('low_stock')) {
// consider low stock when available <= threshold (default 1)
$threshold = $request->integer('threshold', 1);
$query->where('available', '<=', $threshold);
}
$inventories = $query->latest('id')->paginate(25)->withQueryString();
$rentalCompanies = RentalCompany::orderBy('company_name')->get();
return view('super-admin.inventories.index', compact('inventories', 'rentalCompanies'));
}
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers\SuperAdmin;
use App\Http\Controllers\Controller;
use App\Http\Requests\RejectRentalRequest;
use App\Models\RentalCompany;
use App\Services\ActivityLogService;
use App\Services\NotificationService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class RentalVerificationController extends Controller
{
public function __construct(
private readonly NotificationService $notificationService,
private readonly ActivityLogService $activityLogService,
) {}
public function index(Request $request): View
{
$request->validate([
'status' => ['nullable', 'in:pending,approved,rejected'],
'search' => ['nullable', 'string', 'max:100'],
]);
$rentals = RentalCompany::query()
->with(['user', 'verifiedBy'])
->withCount(['vehicles', 'bookings'])
->when($request->filled('status'), function ($query) use ($request): void {
$query->where('status_verification', $request->string('status')->toString());
})
->when($request->filled('search'), function ($query) use ($request): void {
$search = $request->string('search')->toString();
$query->where(function ($subQuery) use ($search): void {
$subQuery->where('company_name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%')
->orWhere('city', 'like', '%' . $search . '%')
->orWhereHas('user', function ($userQuery) use ($search): void {
$userQuery->where('name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%');
});
});
})
->latest('id')
->paginate(10)
->withQueryString();
return view('super-admin.rentals.index', compact('rentals'));
}
public function show(RentalCompany $rentalCompany): View
{
$rentalCompany->load(['user', 'verifiedBy']);
$vehicles = $rentalCompany->vehicles()
->withCount('bookings')
->latest('id')
->take(10)
->get();
$totalVehicles = $rentalCompany->vehicles()->count();
$totalBookings = $rentalCompany->bookings()->count();
return view('super-admin.rentals.show', compact('rentalCompany', 'vehicles', 'totalVehicles', 'totalBookings'));
}
public function approve(RentalCompany $rentalCompany): RedirectResponse
{
if (!in_array($rentalCompany->status_verification, [
RentalCompany::STATUS_PENDING,
RentalCompany::STATUS_REJECTED,
], true)) {
return back()->with('error', 'Rental ini tidak berada pada status yang dapat disetujui.');
}
$rentalCompany->update([
'status_verification' => RentalCompany::STATUS_APPROVED,
'verified_by' => Auth::id(),
'verified_at' => now(),
'rejection_note' => null,
]);
$this->notificationService->notifyUser(
userId: (int) $rentalCompany->user_id,
title: 'Pendaftaran Rental Disetujui',
message: 'Pendaftaran rental "' . $rentalCompany->company_name . '" telah disetujui oleh admin super. Anda sekarang bisa mengelola kendaraan.',
type: 'success',
url: route('admin-rental.dashboard'),
referenceType: 'rental_company',
referenceId: $rentalCompany->id,
);
$this->activityLogService->log(
action: 'rental.approved',
description: 'Super admin menyetujui pendaftaran rental: ' . $rentalCompany->company_name,
targetType: 'rental_company',
targetId: $rentalCompany->id,
meta: ['company_name' => $rentalCompany->company_name, 'city' => $rentalCompany->city]
);
return back()->with('success', 'Rental berhasil disetujui.');
}
public function reject(RejectRentalRequest $request, RentalCompany $rentalCompany): RedirectResponse
{
if (!in_array($rentalCompany->status_verification, [
RentalCompany::STATUS_PENDING,
RentalCompany::STATUS_APPROVED,
], true)) {
return back()->with('error', 'Rental ini tidak berada pada status yang dapat ditolak.');
}
$validated = $request->validated();
$rentalCompany->update([
'status_verification' => RentalCompany::STATUS_REJECTED,
'verified_by' => Auth::id(),
'verified_at' => now(),
'rejection_note' => $validated['rejection_note'],
]);
$this->notificationService->notifyUser(
userId: (int) $rentalCompany->user_id,
title: 'Pendaftaran Rental Ditolak',
message: 'Pendaftaran rental "' . $rentalCompany->company_name . '" ditolak oleh admin super. Alasan: ' . $validated['rejection_note'],
type: 'error',
url: route('admin-rental.dashboard'),
referenceType: 'rental_company',
referenceId: $rentalCompany->id,
);
$this->activityLogService->log(
action: 'rental.rejected',
description: 'Super admin menolak pendaftaran rental: ' . $rentalCompany->company_name,
targetType: 'rental_company',
targetId: $rentalCompany->id,
meta: ['company_name' => $rentalCompany->company_name, 'rejection_note' => $validated['rejection_note']]
);
return back()->with('success', 'Rental berhasil ditolak.');
}
}

View File

@ -0,0 +1,399 @@
<?php
namespace App\Http\Controllers\SuperAdmin;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\Payment;
use App\Models\RentalCompany;
use App\Models\Review;
use App\Models\User;
use App\Models\Vehicle;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class ReportController extends Controller
{
private float $commissionRate = 10.0; // 10% commission
/**
* Dashboard ringkas laporan
*/
public function index(Request $request): View
{
$dateFilter = $this->getDateFilter($request);
// Total Booking
$totalBookings = $this->getBaseBookingsQuery($request, $dateFilter)->count();
// Total Payment Verified
$totalPaymentsVerified = $this->getBaseBookingsQuery($request, $dateFilter)
->where('payment_status', Booking::PAYMENT_VERIFIED)
->count();
// Total Revenue (dari payment verified)
$totalRevenue = (float) ($this->getBaseBookingsQuery($request, $dateFilter)
->where('payment_status', Booking::PAYMENT_VERIFIED)
->sum('total_amount') ?? 0);
// Total Commission
$totalCommission = round($totalRevenue * ($this->commissionRate / 100), 2);
// Total Vehicles
$totalVehicles = Vehicle::count();
// Total Active Customers (yang pernah booking)
$totalActiveCustomers = User::query()
->where('role', 'customer')
->whereHas('bookings')
->count();
return view('super-admin.reports.index', compact(
'totalBookings',
'totalPaymentsVerified',
'totalRevenue',
'totalCommission',
'totalVehicles',
'totalActiveCustomers'
));
}
/**
* Laporan Booking per Rental
*/
public function bookings(Request $request): View
{
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'rental_id' => ['nullable', 'exists:rental_companies,id'],
'booking_status' => ['nullable', 'in:waiting_payment,waiting_verification,confirmed,ongoing,completed,cancelled'],
]);
$dateFilter = $this->getDateFilter($request);
$baseQuery = Booking::query()
->with(['customer', 'vehicle.rentalCompany', 'payment'])
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']))
->when($request->filled('rental_id'), fn($q) => $q->where('rental_company_id', $request->integer('rental_id')))
->when($request->filled('booking_status'), fn($q) => $q->where('booking_status', $request->string('booking_status')));
// Summary statistics
$summary = [
'total_bookings' => (clone $baseQuery)->count(),
'total_completed' => (clone $baseQuery)->where('booking_status', Booking::BOOKING_COMPLETED)->count(),
'total_cancelled' => (clone $baseQuery)->where('booking_status', Booking::BOOKING_CANCELLED)->count(),
'total_ongoing' => (clone $baseQuery)->where('booking_status', Booking::BOOKING_ONGOING)->count(),
];
$bookings = (clone $baseQuery)
->latest('id')
->paginate(15)
->withQueryString();
$rentalCompanies = RentalCompany::orderBy('company_name')->get(['id', 'company_name']);
$bookingStatuses = Booking::statusOptions();
return view('super-admin.reports.bookings', compact(
'bookings',
'summary',
'rentalCompanies',
'bookingStatuses'
));
}
/**
* Laporan Pembayaran
*/
public function payments(Request $request): View
{
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'rental_id' => ['nullable', 'exists:rental_companies,id'],
'payment_status' => ['nullable', 'in:unpaid,uploaded,verified,rejected'],
]);
$dateFilter = $this->getDateFilter($request);
$baseQuery = Booking::query()
->with(['customer', 'vehicle.rentalCompany', 'payment'])
->whereHas('payment')
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']))
->when($request->filled('rental_id'), fn($q) => $q->where('rental_company_id', $request->integer('rental_id')))
->when($request->filled('payment_status'), fn($q) => $q->where('payment_status', $request->string('payment_status')));
// Summary statistics
$summary = [
'total_payments_verified' => (clone $baseQuery)->where('payment_status', Booking::PAYMENT_VERIFIED)->count(),
'total_payments_uploaded' => (clone $baseQuery)->where('payment_status', Booking::PAYMENT_UPLOADED)->count(),
'total_payments_rejected' => (clone $baseQuery)->where('payment_status', Booking::PAYMENT_REJECTED)->count(),
'total_nominal_verified' => (float) ((clone $baseQuery)->where('payment_status', Booking::PAYMENT_VERIFIED)->sum('total_amount') ?? 0),
];
$bookings = (clone $baseQuery)
->latest('id')
->paginate(15)
->withQueryString();
$rentalCompanies = RentalCompany::orderBy('company_name')->get(['id', 'company_name']);
$paymentStatuses = Booking::paymentStatusOptions();
return view('super-admin.reports.payments', compact(
'bookings',
'summary',
'rentalCompanies',
'paymentStatuses'
));
}
/**
* Laporan Kendaraan Terlaris
*/
public function topVehicles(Request $request): View
{
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'limit' => ['nullable', 'integer', 'min:5', 'max:100'],
]);
$limit = $request->integer('limit', 20);
$dateFilter = $this->getDateFilter($request);
// Kendaraan terlaris berdasarkan jumlah booking dengan payment verified
$topVehicles = Vehicle::query()
->with('rentalCompany')
->withCount([
'bookings as verified_booking_count' => function (Builder $query) use ($dateFilter): void {
$query->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
])
->withSum([
'bookings as total_revenue' => function (Builder $query) use ($dateFilter): void {
$query->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
], 'total_amount')
->whereHas('bookings', function (Builder $query) use ($dateFilter): void {
$query->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
})
->orderByDesc('verified_booking_count')
->limit($limit)
->get();
return view('super-admin.reports.top-vehicles', compact('topVehicles'));
}
/**
* Laporan Customer Aktif
*/
public function activeCustomers(Request $request): View
{
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'limit' => ['nullable', 'integer', 'min:5', 'max:100'],
]);
$limit = $request->integer('limit', 20);
$dateFilter = $this->getDateFilter($request);
// Customer aktif berdasarkan completed bookings
$activeCustomers = User::query()
->where('role', 'customer')
->withCount([
'bookings as total_booking_count' => function (Builder $query) use ($dateFilter): void {
$query->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
'bookings as completed_booking_count' => function (Builder $query) use ($dateFilter): void {
$query->where('booking_status', Booking::BOOKING_COMPLETED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
])
->withMax([
'bookings as last_booking_date' => function (Builder $query) use ($dateFilter): void {
$query->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
], 'created_at')
->withSum([
'bookings as total_transaction' => function (Builder $query) use ($dateFilter): void {
$query->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
], 'total_amount')
->whereHas('bookings', function (Builder $query) use ($dateFilter): void {
$query->where('booking_status', Booking::BOOKING_COMPLETED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
})
->orderByDesc('completed_booking_count')
->limit($limit)
->get();
// Check loyal threshold (3 completed bookings)
$loyalThreshold = 3;
return view('super-admin.reports.active-customers', compact('activeCustomers', 'loyalThreshold'));
}
/**
* Laporan Pendapatan Rental
*/
public function revenue(Request $request): View
{
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
]);
$dateFilter = $this->getDateFilter($request);
// Revenue per rental
$revenues = RentalCompany::query()
->with(['user'])
->withCount([
'bookings as verified_booking_count' => function (Builder $query) use ($dateFilter): void {
$query->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
])
->withSum([
'bookings as total_revenue' => function (Builder $query) use ($dateFilter): void {
$query->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
},
], 'total_amount')
->where('status_verification', RentalCompany::STATUS_APPROVED)
->whereHas('bookings', function (Builder $query) use ($dateFilter): void {
$query->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
})
->orderByDesc('total_revenue')
->get();
// Calculate commission for each rental
$revenues = $revenues->map(function ($rental) {
$gross = (float) ($rental->total_revenue ?? 0);
$commission = round($gross * ($this->commissionRate / 100), 2);
$net = round($gross - $commission, 2);
return [
'rental' => $rental,
'verified_booking_count' => $rental->verified_booking_count ?? 0,
'gross_revenue' => $gross,
'commission' => $commission,
'net_revenue' => $net,
];
});
// Total summary
$totalGross = $revenues->sum('gross_revenue');
$totalCommission = $revenues->sum('commission');
$totalNet = $revenues->sum('net_revenue');
return view('super-admin.reports.revenue', compact(
'revenues',
'totalGross',
'totalCommission',
'totalNet'
));
}
/**
* Laporan Komisi Platform
*/
public function commissions(Request $request): View
{
$request->validate([
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'rental_id' => ['nullable', 'exists:rental_companies,id'],
'rental_company_id' => ['nullable', 'exists:rental_companies,id'],
'payment_status' => ['nullable', 'in:unpaid,uploaded,verified,rejected'],
'booking_status' => ['nullable', 'in:waiting_payment,waiting_verification,confirmed,ongoing,completed,cancelled'],
]);
$dateFilter = $this->getDateFilter($request);
$selectedRentalId = $request->integer('rental_company_id') ?: $request->integer('rental_id');
// Commission dari transaksi verified
$baseQuery = Booking::query()
->with(['rentalCompany', 'customer'])
->where('payment_status', Booking::PAYMENT_VERIFIED)
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']))
->when($selectedRentalId, fn($q) => $q->where('rental_company_id', $selectedRentalId))
->when($request->filled('payment_status'), fn($q) => $q->where('payment_status', $request->string('payment_status')))
->when($request->filled('booking_status'), fn($q) => $q->where('booking_status', $request->string('booking_status')));
// Summary
$summary = [
'total_transactions' => (clone $baseQuery)->count(),
'total_gross_revenue' => (float) ((clone $baseQuery)->sum('total_amount') ?? 0),
];
$summary['total_commission'] = round($summary['total_gross_revenue'] * ($this->commissionRate / 100), 2);
$summary['avg_commission_per_booking'] = $summary['total_transactions'] > 0
? round($summary['total_commission'] / $summary['total_transactions'], 2)
: 0;
$commissions = (clone $baseQuery)
->latest('id')
->paginate(20)
->withQueryString();
// Add commission calculation to each commission item
$commissions->getCollection()->transform(function ($booking) {
$booking->commission_amount = round($booking->total_amount * ($this->commissionRate / 100), 2);
return $booking;
});
$rentalCompanies = RentalCompany::orderBy('company_name')->get(['id', 'company_name']);
return view('super-admin.commissions.index', [
'bookings' => $commissions,
'commissionRate' => $this->commissionRate,
'totalTransaction' => $summary['total_gross_revenue'],
'totalCommission' => $summary['total_commission'],
'rentalOptions' => $rentalCompanies,
'bookingStatuses' => Booking::statusOptions(),
]);
}
/**
* Helper: Get date filter array
*/
private function getDateFilter(Request $request): array
{
return [
'start' => $request->filled('start_date') ? $request->date('start_date')->startOfDay() : null,
'end' => $request->filled('end_date') ? $request->date('end_date')->endOfDay() : null,
];
}
/**
* Helper: Get base bookings query with filters
*/
private function getBaseBookingsQuery(Request $request, array $dateFilter): Builder
{
return Booking::query()
->when($dateFilter['start'], fn($q) => $q->where('created_at', '>=', $dateFilter['start']))
->when($dateFilter['end'], fn($q) => $q->where('created_at', '<=', $dateFilter['end']));
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers\SuperAdmin;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class UserController extends Controller
{
public function index(Request $request): View
{
$request->validate([
'role' => ['nullable', 'in:customer,admin_rental,super_admin'],
'search' => ['nullable', 'string', 'max:100'],
]);
$users = User::query()
->when($request->filled('role'), function ($query) use ($request): void {
$query->where('role', $request->string('role')->toString());
})
->when($request->filled('search'), function ($query) use ($request): void {
$search = $request->string('search')->toString();
$query->where(function ($subQuery) use ($search): void {
$subQuery->where('name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%');
});
})
->latest('id')
->paginate(12)
->withQueryString();
return view('super-admin.users.index', compact('users'));
}
public function show(User $user): View
{
$detail = [];
if ($user->role === 'customer') {
$bookingQuery = Booking::query()->where('customer_id', $user->id);
$detail = [
'booking_count' => (clone $bookingQuery)->count(),
'completed_booking_count' => (clone $bookingQuery)->where('booking_status', Booking::BOOKING_COMPLETED)->count(),
'verified_transaction_total' => (float) ((clone $bookingQuery)->where('payment_status', Booking::PAYMENT_VERIFIED)->sum('total_amount') ?? 0),
'review_count' => $user->reviews()->count(),
];
}
if ($user->role === 'admin_rental') {
$rentalCompany = $user->rentalCompany;
$detail = [
'rental_company' => $rentalCompany,
'vehicle_count' => $rentalCompany?->vehicles()->count() ?? 0,
'booking_count' => $rentalCompany?->bookings()->count() ?? 0,
];
}
return view('super-admin.users.show', compact('user', 'detail'));
}
public function destroy(User $user): RedirectResponse
{
if ($user->role === 'super_admin') {
return redirect()->route('super-admin.users.index')
->with('error', 'Akun Super Admin tidak dapat dihapus.');
}
if ($user->id === Auth::id()) {
return redirect()->route('super-admin.users.index')
->with('error', 'Anda tidak dapat menghapus akun Anda sendiri.');
}
if ($user->role === 'customer') {
$user->reviews()->delete();
$user->bookings()->delete();
}
if ($user->role === 'admin_rental') {
$rentalCompany = $user->rentalCompany;
if ($rentalCompany) {
$rentalCompany->bookings()->each(function ($booking): void {
$booking->review()->delete();
$booking->delete();
});
$rentalCompany->vehicles()->each(function ($vehicle): void {
$vehicle->inventory()->delete();
$vehicle->delete();
});
$rentalCompany->delete();
}
}
$user->delete();
return redirect()->route('super-admin.users.index')
->with('success', 'Akun ' . $user->name . ' berhasil dihapus.');
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RoleMiddleware
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next, string ...$roles): Response
{
$user = $request->user();
if (!$user) {
return redirect()->route('login');
}
if (!in_array($user->role, $roles, true)) {
abort(403, 'Anda tidak memiliki akses ke halaman ini.');
}
return $next($request);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CancelBookingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'cancel_reason' => ['nullable', 'string', 'max:1000'],
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RejectPaymentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'rejection_note' => ['required', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'rejection_note.required' => 'Alasan penolakan wajib diisi.',
'rejection_note.max' => 'Alasan penolakan terlalu panjang.',
];
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RejectRentalRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'rejection_note' => ['required', 'string', 'min:5', 'max:1000'],
];
}
public function messages(): array
{
return [
'rejection_note.required' => 'Alasan penolakan wajib diisi.',
'rejection_note.min' => 'Alasan penolakan minimal 5 karakter.',
'rejection_note.max' => 'Alasan penolakan maksimal 1000 karakter.',
];
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreBookingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'customer_name' => ['required', 'string', 'max:100'],
'customer_email' => ['required', 'email', 'max:255'],
'customer_phone' => ['required', 'string', 'max:20'],
'customer_address' => ['required', 'string', 'max:500'],
'identity_number' => ['required', 'string', 'max:50'],
'driver_license_number' => ['nullable', 'string', 'max:50'],
'pickup_date' => ['required', 'date', 'after_or_equal:today'],
'return_date' => ['required', 'date', 'after_or_equal:pickup_date'],
'pickup_time' => ['nullable', 'date_format:H:i'],
'pickup_location' => ['required', 'string', 'max:255'],
'return_location' => ['nullable', 'string', 'max:255'],
'with_driver' => ['nullable', 'boolean'],
'promo_code' => ['nullable', 'string', 'max:50'],
'note' => ['nullable', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'customer_name.required' => 'Nama lengkap wajib diisi.',
'customer_email.required' => 'Email wajib diisi.',
'customer_email.email' => 'Format email tidak valid.',
'customer_phone.required' => 'Nomor HP wajib diisi.',
'customer_address.required' => 'Alamat wajib diisi.',
'identity_number.required' => 'Nomor KTP wajib diisi.',
'pickup_date.required' => 'Tanggal mulai sewa wajib diisi.',
'pickup_date.after_or_equal' => 'Tanggal mulai sewa tidak boleh di masa lalu.',
'return_date.required' => 'Tanggal selesai sewa wajib diisi.',
'return_date.after_or_equal' => 'Tanggal selesai sewa tidak boleh lebih kecil dari tanggal mulai sewa.',
'pickup_location.required' => 'Lokasi pengambilan wajib diisi.',
'pickup_time.date_format' => 'Format jam pengambilan tidak valid.',
'with_driver.boolean' => 'Pilihan driver tidak valid.',
'promo_code.max' => 'Kode promo maksimal 50 karakter.',
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use App\Models\Promo;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePromoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:150'],
'promo_code' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('promos', 'promo_code')],
'description' => ['nullable', 'string', 'max:1000'],
'discount_type' => ['required', Rule::in([Promo::DISCOUNT_PERCENT, Promo::DISCOUNT_FIXED])],
'discount_value' => ['required', 'numeric', 'gt:0'],
'min_transaction' => ['nullable', 'numeric', 'min:0'],
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
'quota' => ['nullable', 'integer', 'min:1'],
'loyal_only' => ['nullable', 'boolean'],
'status' => ['required', Rule::in([Promo::STATUS_ACTIVE, Promo::STATUS_INACTIVE])],
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreReviewRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'rating' => ['required', 'integer', 'min:1', 'max:5'],
'review' => ['nullable', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'rating.required' => 'Rating wajib dipilih.',
'rating.integer' => 'Rating harus berupa angka.',
'rating.min' => 'Rating minimal 1.',
'rating.max' => 'Rating maksimal 5.',
'review.max' => 'Ulasan terlalu panjang.',
];
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreVehicleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'brand' => ['required', 'string', 'max:100'],
'type' => ['required', 'string', 'max:100'],
'category' => ['required', 'string', 'max:100'],
'year' => ['required', 'integer', 'min:1900', 'max:' . ((int) date('Y') + 1)],
'transmission' => ['required', 'string', 'max:50'],
'fuel_type' => ['required', 'string', 'max:50'],
'seat_capacity' => ['required', 'integer', 'min:1', 'max:99'],
'luggage_capacity' => ['nullable', 'integer', 'min:0', 'max:99'],
'color' => ['nullable', 'string', 'max:50'],
'price_per_day' => ['required', 'integer', 'min:0'],
'description' => ['nullable', 'string'],
'terms_conditions' => ['nullable', 'string'],
'status' => ['required', 'in:active,inactive,maintenance'],
'main_image' => ['nullable', 'file', 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp', 'max:4096'],
'gallery_images' => ['nullable', 'array', 'max:8'],
'gallery_images.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp', 'max:4096'],
];
}
public function messages(): array
{
return [
'name.required' => 'Nama kendaraan wajib diisi.',
'brand.required' => 'Brand wajib diisi.',
'type.required' => 'Tipe kendaraan wajib diisi.',
'category.required' => 'Kategori wajib diisi.',
'year.required' => 'Tahun kendaraan wajib diisi.',
'year.integer' => 'Tahun kendaraan harus berupa angka.',
'transmission.required' => 'Transmisi wajib diisi.',
'fuel_type.required' => 'Jenis bahan bakar wajib diisi.',
'seat_capacity.required' => 'Kapasitas kursi wajib diisi.',
'seat_capacity.integer' => 'Kapasitas kursi harus berupa angka.',
'luggage_capacity.integer' => 'Kapasitas bagasi harus berupa angka.',
'price_per_day.required' => 'Harga per hari wajib diisi.',
'price_per_day.numeric' => 'Harga per hari harus berupa angka.',
'status.required' => 'Status kendaraan wajib diisi.',
'status.in' => 'Status kendaraan tidak valid.',
'main_image.file' => 'Foto utama harus berupa file yang valid.',
'main_image.mimes' => 'Foto utama hanya boleh JPG, JPEG, PNG, atau WEBP.',
'main_image.max' => 'Ukuran foto utama maksimal 4MB.',
'gallery_images.array' => 'Galeri harus berupa daftar gambar.',
'gallery_images.max' => 'Maksimal 8 gambar galeri per upload.',
'gallery_images.*.file' => 'Setiap file galeri harus valid.',
'gallery_images.*.mimes' => 'Setiap gambar galeri hanya boleh JPG, JPEG, PNG, atau WEBP.',
'gallery_images.*.max' => 'Ukuran tiap gambar galeri maksimal 4MB.',
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use App\Models\Promo;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePromoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
/** @var Promo|null $promo */
$promo = $this->route('promo');
return [
'title' => ['required', 'string', 'max:150'],
'promo_code' => ['required', 'string', 'max:50', 'alpha_dash', Rule::unique('promos', 'promo_code')->ignore($promo?->id)],
'description' => ['nullable', 'string', 'max:1000'],
'discount_type' => ['required', Rule::in([Promo::DISCOUNT_PERCENT, Promo::DISCOUNT_FIXED])],
'discount_value' => ['required', 'numeric', 'gt:0'],
'min_transaction' => ['nullable', 'numeric', 'min:0'],
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
'quota' => ['nullable', 'integer', 'min:1'],
'loyal_only' => ['nullable', 'boolean'],
'status' => ['required', Rule::in([Promo::STATUS_ACTIVE, Promo::STATUS_INACTIVE])],
];
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateVehicleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'brand' => ['required', 'string', 'max:100'],
'type' => ['required', 'string', 'max:100'],
'category' => ['required', 'string', 'max:100'],
'year' => ['required', 'integer', 'min:1900', 'max:' . ((int) date('Y') + 1)],
'transmission' => ['required', 'string', 'max:50'],
'fuel_type' => ['required', 'string', 'max:50'],
'seat_capacity' => ['required', 'integer', 'min:1', 'max:99'],
'luggage_capacity' => ['nullable', 'integer', 'min:0', 'max:99'],
'color' => ['nullable', 'string', 'max:50'],
'price_per_day' => ['required', 'integer', 'min:0'],
'description' => ['nullable', 'string'],
'terms_conditions' => ['nullable', 'string'],
'status' => ['required', 'in:active,inactive,maintenance'],
'main_image' => ['nullable', 'file', 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp', 'max:4096'],
'gallery_images' => ['nullable', 'array', 'max:8'],
'gallery_images.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp', 'max:4096'],
'delete_gallery_images' => ['nullable', 'array'],
'delete_gallery_images.*' => ['integer'],
];
}
public function messages(): array
{
return [
'name.required' => 'Nama kendaraan wajib diisi.',
'brand.required' => 'Brand wajib diisi.',
'type.required' => 'Tipe kendaraan wajib diisi.',
'category.required' => 'Kategori wajib diisi.',
'year.required' => 'Tahun kendaraan wajib diisi.',
'year.integer' => 'Tahun kendaraan harus berupa angka.',
'transmission.required' => 'Transmisi wajib diisi.',
'fuel_type.required' => 'Jenis bahan bakar wajib diisi.',
'seat_capacity.required' => 'Kapasitas kursi wajib diisi.',
'seat_capacity.integer' => 'Kapasitas kursi harus berupa angka.',
'luggage_capacity.integer' => 'Kapasitas bagasi harus berupa angka.',
'price_per_day.required' => 'Harga per hari wajib diisi.',
'price_per_day.numeric' => 'Harga per hari harus berupa angka.',
'status.required' => 'Status kendaraan wajib diisi.',
'status.in' => 'Status kendaraan tidak valid.',
'main_image.file' => 'Foto utama harus berupa file yang valid.',
'main_image.mimes' => 'Foto utama hanya boleh JPG, JPEG, PNG, atau WEBP.',
'main_image.max' => 'Ukuran foto utama maksimal 4MB.',
'gallery_images.array' => 'Galeri harus berupa daftar gambar.',
'gallery_images.max' => 'Maksimal 8 gambar galeri per upload.',
'gallery_images.*.file' => 'Setiap file galeri harus valid.',
'gallery_images.*.mimes' => 'Setiap gambar galeri hanya boleh JPG, JPEG, PNG, atau WEBP.',
'gallery_images.*.max' => 'Ukuran tiap gambar galeri maksimal 4MB.',
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UploadPaymentProofRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'payment_method' => ['required', 'string', Rule::in(array_keys(config('payment_methods')))],
'proof_payment' => ['required', 'file', 'mimes:jpg,jpeg,png,pdf', 'mimetypes:image/jpeg,image/png,application/pdf', 'max:5120'],
];
}
public function messages(): array
{
return [
'payment_method.required' => 'Metode pembayaran wajib dipilih.',
'payment_method.in' => 'Metode pembayaran tidak valid.',
'proof_payment.required' => 'Bukti pembayaran wajib diupload.',
'proof_payment.file' => 'Bukti pembayaran harus berupa file yang valid.',
'proof_payment.mimes' => 'Bukti pembayaran harus berformat JPG, PNG, JPEG, atau PDF.',
'proof_payment.mimetypes' => 'Tipe file bukti pembayaran tidak valid.',
'proof_payment.max' => 'Ukuran file bukti pembayaran maksimal 5MB.',
];
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ActivityLog extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'user_id',
'action',
'target_type',
'target_id',
'description',
'meta',
'created_at',
];
protected $casts = [
'meta' => 'array',
'created_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

143
app/Models/Booking.php Normal file
View File

@ -0,0 +1,143 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Booking extends Model
{
use HasFactory;
public const BOOKING_PENDING = 'pending';
public const BOOKING_WAITING_PAYMENT = 'waiting_payment';
public const BOOKING_WAITING_VERIFICATION = 'waiting_verification';
public const BOOKING_CONFIRMED = 'confirmed';
public const BOOKING_ONGOING = 'ongoing';
public const BOOKING_COMPLETED = 'completed';
public const BOOKING_CANCELLED = 'cancelled';
public const PAYMENT_UNPAID = 'unpaid';
public const PAYMENT_UPLOADED = 'uploaded';
public const PAYMENT_VERIFIED = 'verified';
public const PAYMENT_REJECTED = 'rejected';
public static function statusOptions(): array
{
return [
self::BOOKING_WAITING_PAYMENT => 'Menunggu Pembayaran',
self::BOOKING_WAITING_VERIFICATION => 'Menunggu Verifikasi',
self::BOOKING_CONFIRMED => 'Dikonfirmasi',
self::BOOKING_ONGOING => 'Sedang Berjalan',
self::BOOKING_COMPLETED => 'Selesai',
self::BOOKING_CANCELLED => 'Dibatalkan',
];
}
public static function paymentStatusOptions(): array
{
return [
self::PAYMENT_UNPAID => 'Belum Bayar',
self::PAYMENT_UPLOADED => 'Uploaded',
self::PAYMENT_VERIFIED => 'Verified',
self::PAYMENT_REJECTED => 'Rejected',
];
}
public function getBookingStatusLabelAttribute(): string
{
return self::statusOptions()[$this->booking_status] ?? ucfirst((string) $this->booking_status);
}
public function getPaymentStatusLabelAttribute(): string
{
return self::paymentStatusOptions()[$this->payment_status] ?? ucfirst((string) $this->payment_status);
}
public function bookingStatusLabel(): string
{
return $this->booking_status_label;
}
public function paymentStatusLabel(): string
{
return $this->payment_status_label;
}
protected $fillable = [
'booking_code',
'customer_id',
'rental_company_id',
'vehicle_id',
'promo_id',
'pickup_date',
'return_date',
'pickup_time',
'pickup_location',
'return_location',
'duration_days',
'with_driver',
'customer_name',
'customer_email',
'customer_phone',
'customer_address',
'identity_number',
'driver_license_number',
'note',
'subtotal',
'discount_amount',
'additional_cost',
'total_amount',
'booking_status',
'payment_status',
];
protected $casts = [
'pickup_date' => 'date',
'return_date' => 'date',
'pickup_time' => 'datetime:H:i',
'duration_days' => 'integer',
'with_driver' => 'boolean',
'subtotal' => 'decimal:2',
'discount_amount' => 'decimal:2',
'additional_cost' => 'decimal:2',
'total_amount' => 'decimal:2',
];
public function getRouteKeyName(): string
{
return 'booking_code';
}
public function customer(): BelongsTo
{
return $this->belongsTo(User::class, 'customer_id');
}
public function rentalCompany(): BelongsTo
{
return $this->belongsTo(RentalCompany::class);
}
public function vehicle(): BelongsTo
{
return $this->belongsTo(Vehicle::class);
}
public function promo(): BelongsTo
{
return $this->belongsTo(Promo::class);
}
public function payment(): HasOne
{
return $this->hasOne(Payment::class);
}
public function review(): HasOne
{
return $this->hasOne(Review::class);
}
}

38
app/Models/Inventory.php Normal file
View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Inventory extends Model
{
use HasFactory;
protected $fillable = [
'rental_company_id',
'vehicle_id',
'total',
'reserved',
'available',
'last_checked_by',
'notes',
];
public function vehicle(): BelongsTo
{
return $this->belongsTo(Vehicle::class);
}
public function rentalCompany(): BelongsTo
{
return $this->belongsTo(RentalCompany::class);
}
public function lastCheckedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'last_checked_by');
}
}

55
app/Models/Payment.php Normal file
View File

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Payment extends Model
{
use HasFactory;
public const STATUS_UNPAID = 'unpaid';
public const STATUS_UPLOADED = 'uploaded';
public const STATUS_VERIFIED = 'verified';
public const STATUS_REJECTED = 'rejected';
protected $fillable = [
'booking_id',
'payment_method',
'amount',
'proof_payment',
'paid_at',
'verified_by',
'verified_at',
'payment_status',
'rejection_note',
];
protected $casts = [
'amount' => 'decimal:2',
'paid_at' => 'datetime',
'verified_at' => 'datetime',
];
public function getStatusLabelAttribute(): string
{
return match ($this->payment_status) {
self::STATUS_UPLOADED => 'Menunggu Verifikasi',
self::STATUS_VERIFIED => 'Diverifikasi',
self::STATUS_REJECTED => 'Ditolak',
default => 'Belum Bayar',
};
}
public function booking(): BelongsTo
{
return $this->belongsTo(Booking::class);
}
public function verifiedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by');
}
}

64
app/Models/Promo.php Normal file
View File

@ -0,0 +1,64 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Promo extends Model
{
use HasFactory;
public const DISCOUNT_PERCENT = 'percent';
public const DISCOUNT_FIXED = 'fixed';
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
protected $fillable = [
'rental_company_id',
'title',
'promo_code',
'description',
'discount_type',
'discount_value',
'min_transaction',
'start_date',
'end_date',
'quota',
'used_count',
'loyal_only',
'status',
];
protected $casts = [
'discount_value' => 'decimal:2',
'min_transaction' => 'decimal:2',
'start_date' => 'date',
'end_date' => 'date',
'quota' => 'integer',
'used_count' => 'integer',
'loyal_only' => 'boolean',
];
public function rentalCompany(): BelongsTo
{
return $this->belongsTo(RentalCompany::class);
}
public function bookings(): HasMany
{
return $this->hasMany(Booking::class);
}
public function getDiscountLabelAttribute(): string
{
if ($this->discount_type === self::DISCOUNT_PERCENT) {
return rtrim(rtrim(number_format((float) $this->discount_value, 2, '.', ''), '0'), '.') . '%';
}
return 'Rp ' . number_format((float) $this->discount_value, 0, ',', '.');
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Models;
use App\Services\SlugService;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class RentalCompany extends Model
{
use HasFactory;
protected static function booted(): void
{
static::creating(function (self $rentalCompany): void {
if (!$rentalCompany->company_slug && $rentalCompany->company_name) {
$rentalCompany->company_slug = app(SlugService::class)->generateUnique(self::class, 'company_slug', $rentalCompany->company_name);
}
});
static::updating(function (self $rentalCompany): void {
if ($rentalCompany->isDirty('company_name')) {
$rentalCompany->company_slug = app(SlugService::class)->generateUnique(self::class, 'company_slug', (string) $rentalCompany->company_name, $rentalCompany->id);
}
});
}
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
protected $fillable = [
'user_id',
'company_name',
'company_slug',
'description',
'address',
'city',
'phone',
'email',
'logo',
'document',
'status_verification',
'verified_by',
'verified_at',
'rejection_note',
];
protected $casts = [
'verified_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function verifiedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by');
}
public function vehicles(): HasMany
{
return $this->hasMany(Vehicle::class);
}
public function bookings(): HasMany
{
return $this->hasMany(Booking::class);
}
public function promos(): HasMany
{
return $this->hasMany(Promo::class);
}
public function reviews(): HasMany
{
return $this->hasMany(Review::class);
}
public function scopeApproved(Builder $query): Builder
{
return $query->where('status_verification', self::STATUS_APPROVED);
}
}

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

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Review extends Model
{
use HasFactory;
protected $fillable = [
'booking_id',
'customer_id',
'vehicle_id',
'rental_company_id',
'rating',
'review',
];
protected $casts = [
'rating' => 'integer',
];
public function booking(): BelongsTo
{
return $this->belongsTo(Booking::class);
}
public function customer(): BelongsTo
{
return $this->belongsTo(User::class, 'customer_id');
}
public function vehicle(): BelongsTo
{
return $this->belongsTo(Vehicle::class);
}
public function rentalCompany(): BelongsTo
{
return $this->belongsTo(RentalCompany::class);
}
}

69
app/Models/User.php Normal file
View File

@ -0,0 +1,69 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'phone', 'password', 'role'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function rentalCompany(): HasOne
{
return $this->hasOne(RentalCompany::class);
}
public function bookings(): HasMany
{
return $this->hasMany(Booking::class, 'customer_id');
}
public function verifiedRentalCompanies(): HasMany
{
return $this->hasMany(RentalCompany::class, 'verified_by');
}
public function verifiedPayments(): HasMany
{
return $this->hasMany(Payment::class, 'verified_by');
}
public function reviews(): HasMany
{
return $this->hasMany(Review::class, 'customer_id');
}
public function userNotifications(): HasMany
{
return $this->hasMany(UserNotification::class, 'user_id');
}
public function activityLogs(): HasMany
{
return $this->hasMany(ActivityLog::class, 'user_id');
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserNotification extends Model
{
use HasFactory;
protected $table = 'notifications';
protected $fillable = [
'user_id',
'title',
'message',
'type',
'reference_type',
'reference_id',
'url',
'read_at',
];
protected $casts = [
'read_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function markAsRead(): void
{
if (!$this->read_at) {
$this->forceFill(['read_at' => now()])->save();
}
}
}

88
app/Models/Vehicle.php Normal file
View File

@ -0,0 +1,88 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\Inventory;
class Vehicle extends Model
{
use HasFactory;
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
public const STATUS_MAINTENANCE = 'maintenance';
protected $fillable = [
'rental_company_id',
'name',
'slug',
'brand',
'type',
'category',
'year',
'transmission',
'fuel_type',
'seat_capacity',
'luggage_capacity',
'color',
'price_per_day',
'description',
'terms_conditions',
'status',
'main_image',
];
protected $casts = [
'year' => 'integer',
'seat_capacity' => 'integer',
'price_per_day' => 'integer',
];
public function rentalCompany(): BelongsTo
{
return $this->belongsTo(RentalCompany::class);
}
public function images(): HasMany
{
return $this->hasMany(VehicleImage::class);
}
public function primaryImage(): HasOne
{
return $this->hasOne(VehicleImage::class)->where('is_primary', true);
}
public function bookings(): HasMany
{
return $this->hasMany(Booking::class);
}
public function inventory(): HasOne
{
return $this->hasOne(Inventory::class);
}
public function reviews(): HasMany
{
return $this->hasMany(Review::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('status', self::STATUS_ACTIVE);
}
public function scopeVisibleToCustomers(Builder $query): Builder
{
return $query->active()->whereHas('rentalCompany', function (Builder $rentalQuery): void {
$rentalQuery->approved();
});
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class VehicleImage extends Model
{
use HasFactory;
protected $fillable = [
'vehicle_id',
'image_path',
'is_primary',
];
protected $casts = [
'is_primary' => 'boolean',
];
public function vehicle(): BelongsTo
{
return $this->belongsTo(Vehicle::class);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Policies;
use App\Models\Booking;
use App\Models\User;
class BookingPolicy
{
public function viewAny(User $user): bool
{
return $user->role === 'customer';
}
public function view(User $user, Booking $booking): bool
{
if ($user->role === 'super_admin') {
return true;
}
if ($user->role === 'customer') {
return (int) $booking->customer_id === (int) $user->id;
}
if ($user->role === 'admin_rental') {
return (int) $booking->rental_company_id === (int) $user->rentalCompany?->id;
}
return false;
}
public function create(User $user): bool
{
return $user->role === 'customer';
}
public function update(User $user, Booking $booking): bool
{
return $this->view($user, $booking);
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Policies;
use App\Models\Payment;
use App\Models\User;
class PaymentPolicy
{
public function view(User $user, Payment $payment): bool
{
if ($user->role === 'super_admin') {
return true;
}
if ($user->role === 'customer') {
return (int) $payment->booking?->customer_id === (int) $user->id;
}
if ($user->role === 'admin_rental') {
return (int) $payment->booking?->rental_company_id === (int) $user->rentalCompany?->id;
}
return false;
}
public function update(User $user, Payment $payment): bool
{
return $this->view($user, $payment);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Policies;
use App\Models\Promo;
use App\Models\User;
class PromoPolicy
{
public function view(User $user, Promo $promo): bool
{
if ($user->role === 'super_admin') {
return true;
}
return $user->role === 'admin_rental' && (int) $promo->rental_company_id === (int) $user->rentalCompany?->id;
}
public function create(User $user): bool
{
return $user->role === 'admin_rental' && (bool) $user->rentalCompany;
}
public function update(User $user, Promo $promo): bool
{
return $this->view($user, $promo);
}
public function delete(User $user, Promo $promo): bool
{
return $this->view($user, $promo);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Policies;
use App\Models\RentalCompany;
use App\Models\User;
class RentalCompanyPolicy
{
public function view(User $user, RentalCompany $rentalCompany): bool
{
if ($user->role === 'super_admin') {
return true;
}
return $user->role === 'admin_rental' && (int) $rentalCompany->user_id === (int) $user->id;
}
public function verify(User $user, RentalCompany $rentalCompany): bool
{
return $user->role === 'super_admin';
}
public function update(User $user, RentalCompany $rentalCompany): bool
{
return $this->view($user, $rentalCompany);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Policies;
use App\Models\Booking;
use App\Models\Review;
use App\Models\User;
class ReviewPolicy
{
public function viewAny(User $user): bool
{
return in_array($user->role, ['super_admin', 'admin_rental'], true);
}
public function view(User $user, Review $review): bool
{
if ($user->role === 'super_admin') {
return true;
}
if ($user->role === 'customer') {
return (int) $review->customer_id === (int) $user->id;
}
if ($user->role === 'admin_rental') {
return (int) $review->rental_company_id === (int) $user->rentalCompany?->id;
}
return false;
}
public function create(User $user, Booking $booking): bool
{
return $user->role === 'customer'
&& (int) $booking->customer_id === (int) $user->id
&& $booking->booking_status === Booking::BOOKING_COMPLETED
&& !$booking->review()->exists();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Policies;
use App\Models\User;
class UserPolicy
{
public function viewAny(User $user): bool
{
return in_array($user->role, ['super_admin', 'admin_rental'], true);
}
public function view(User $user, User $model): bool
{
return in_array($user->role, ['super_admin', 'admin_rental'], true);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Vehicle;
class VehiclePolicy
{
public function view(User $user, Vehicle $vehicle): bool
{
if ($user->role === 'super_admin') {
return true;
}
return $user->role === 'admin_rental' && (int) $vehicle->rental_company_id === (int) $user->rentalCompany?->id;
}
public function create(User $user): bool
{
return $user->role === 'admin_rental' && (bool) $user->rentalCompany;
}
public function update(User $user, Vehicle $vehicle): bool
{
return $this->view($user, $vehicle);
}
public function delete(User $user, Vehicle $vehicle): bool
{
return $this->view($user, $vehicle);
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Providers;
use App\Models\Booking;
use App\Models\Payment;
use App\Models\Promo;
use App\Models\RentalCompany;
use App\Models\Review;
use App\Models\UserNotification;
use App\Models\User;
use App\Models\Vehicle;
use App\Policies\BookingPolicy;
use App\Policies\PaymentPolicy;
use App\Policies\PromoPolicy;
use App\Policies\RentalCompanyPolicy;
use App\Policies\ReviewPolicy;
use App\Policies\VehiclePolicy;
use App\Policies\UserPolicy;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Paginator::useBootstrapFive();
Gate::policy(Vehicle::class, VehiclePolicy::class);
Gate::policy(Booking::class, BookingPolicy::class);
Gate::policy(Payment::class, PaymentPolicy::class);
Gate::policy(Promo::class, PromoPolicy::class);
Gate::policy(Review::class, ReviewPolicy::class);
Gate::policy(RentalCompany::class, RentalCompanyPolicy::class);
Gate::policy(User::class, UserPolicy::class);
view()->composer('*', function ($view): void {
$user = auth()->user();
if (!$user) {
$view->with('layoutUnreadNotificationsCount', 0);
$view->with('layoutRecentNotifications', collect());
return;
}
$recentNotifications = UserNotification::query()
->where('user_id', (int) $user->id)
->latest('id')
->take(5)
->get();
$view->with('layoutUnreadNotificationsCount', $recentNotifications->whereNull('read_at')->count());
$view->with('layoutRecentNotifications', $recentNotifications);
});
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Services;
use App\Models\ActivityLog;
use Illuminate\Support\Facades\Auth;
class ActivityLogService
{
public function log(string $action, string $description, ?string $targetType = null, ?int $targetId = null, ?array $meta = null, ?int $userId = null): void
{
ActivityLog::create([
'user_id' => $userId ?? Auth::id(),
'action' => $action,
'target_type' => $targetType,
'target_id' => $targetId,
'description' => $description,
'meta' => $meta,
'created_at' => now(),
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FileUploadService
{
public function storePublic(UploadedFile $file, string $directory): string
{
$safeDirectory = trim($directory, '/');
$filename = Str::uuid()->toString() . '.' . strtolower($file->getClientOriginalExtension());
return $file->storeAs($safeDirectory . '/' . now()->format('Y/m'), $filename, 'public');
}
public function deletePublic(?string $path): void
{
if ($path && Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Models\UserNotification;
use Illuminate\Database\Eloquent\Collection;
class NotificationService
{
public function notifyUser(int $userId, string $title, string $message, string $type = 'info', ?string $url = null, ?string $referenceType = null, ?int $referenceId = null): void
{
UserNotification::create([
'user_id' => $userId,
'title' => $title,
'message' => $message,
'type' => $type,
'url' => $url,
'reference_type' => $referenceType,
'reference_id' => $referenceId,
]);
}
public function notifyUsers(iterable $userIds, string $title, string $message, string $type = 'info', ?string $url = null, ?string $referenceType = null, ?int $referenceId = null): void
{
foreach ($userIds as $userId) {
$this->notifyUser((int) $userId, $title, $message, $type, $url, $referenceType, $referenceId);
}
}
public function notifyRole(string $role, string $title, string $message, string $type = 'info', ?string $url = null, ?string $referenceType = null, ?int $referenceId = null): void
{
$userIds = User::query()->where('role', $role)->pluck('id');
$this->notifyUsers($userIds, $title, $message, $type, $url, $referenceType, $referenceId);
}
public function unreadForUser(int $userId): Collection
{
return UserNotification::query()
->where('user_id', $userId)
->whereNull('read_at')
->latest('id')
->get();
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Services;
use App\Models\Booking;
use App\Models\Promo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
class PromoService
{
public const LOYAL_COMPLETED_THRESHOLD = 2;
public function getVisiblePromosForBooking(int $rentalCompanyId, int $customerId, float $subtotal): Collection
{
$today = now()->toDateString();
$isLoyal = $this->isLoyalCustomer($customerId, $rentalCompanyId);
$promos = Promo::query()
->where('rental_company_id', $rentalCompanyId)
->where('status', Promo::STATUS_ACTIVE)
->whereDate('start_date', '<=', $today)
->whereDate('end_date', '>=', $today)
->where(function (Builder $query): void {
$query->whereNull('quota')
->orWhereColumn('used_count', '<', 'quota');
})
->orderByDesc('id')
->get();
return $promos->map(function (Promo $promo) use ($subtotal, $isLoyal): Promo {
$canUse = true;
$reason = null;
if ($subtotal < (float) $promo->min_transaction) {
$canUse = false;
$reason = 'Subtotal belum memenuhi minimal transaksi promo.';
}
if ($promo->loyal_only && !$isLoyal) {
$canUse = false;
$reason = 'Promo khusus customer loyal.';
}
$promo->setAttribute('can_use', $canUse);
$promo->setAttribute('cannot_use_reason', $reason);
$promo->setAttribute('estimated_discount', $this->calculateDiscount($promo, $subtotal));
return $promo;
});
}
public function resolvePromoForBooking(
?string $promoCode,
int $rentalCompanyId,
int $customerId,
float $subtotal,
bool $lockForUpdate = false
): array {
if (!$promoCode) {
return [
'promo' => null,
'discount_amount' => 0.0,
];
}
$normalizedCode = strtoupper(trim($promoCode));
$query = Promo::query()->where('promo_code', $normalizedCode);
if ($lockForUpdate) {
$query->lockForUpdate();
}
$promo = $query->first();
if (!$promo) {
throw ValidationException::withMessages([
'promo_code' => 'Kode promo tidak ditemukan.',
]);
}
if ((int) $promo->rental_company_id !== $rentalCompanyId) {
throw ValidationException::withMessages([
'promo_code' => 'Promo tidak berlaku untuk rental kendaraan ini.',
]);
}
if ($promo->status !== Promo::STATUS_ACTIVE) {
throw ValidationException::withMessages([
'promo_code' => 'Promo sedang tidak aktif.',
]);
}
$today = Carbon::today();
if ($today->lt($promo->start_date) || $today->gt($promo->end_date)) {
throw ValidationException::withMessages([
'promo_code' => 'Promo tidak berada dalam periode aktif.',
]);
}
if ($promo->quota !== null && $promo->used_count >= $promo->quota) {
throw ValidationException::withMessages([
'promo_code' => 'Kuota promo sudah habis.',
]);
}
if ($subtotal < (float) $promo->min_transaction) {
throw ValidationException::withMessages([
'promo_code' => 'Subtotal belum memenuhi minimal transaksi promo.',
]);
}
if ($promo->loyal_only && !$this->isLoyalCustomer($customerId, $rentalCompanyId)) {
throw ValidationException::withMessages([
'promo_code' => 'Promo ini hanya bisa digunakan customer loyal.',
]);
}
return [
'promo' => $promo,
'discount_amount' => $this->calculateDiscount($promo, $subtotal),
];
}
public function calculateDiscount(Promo $promo, float $subtotal): float
{
if ($subtotal <= 0) {
return 0;
}
$discount = $promo->discount_type === Promo::DISCOUNT_PERCENT
? ($subtotal * ((float) $promo->discount_value / 100))
: (float) $promo->discount_value;
return min(round($discount, 2), $subtotal);
}
public function isLoyalCustomer(int $customerId, int $rentalCompanyId): bool
{
$completedCount = Booking::query()
->where('customer_id', $customerId)
->where('rental_company_id', $rentalCompanyId)
->where('booking_status', Booking::BOOKING_COMPLETED)
->count();
return $completedCount >= self::LOYAL_COMPLETED_THRESHOLD;
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
class SlugService
{
public function generateUnique(string $modelClass, string $column, string $source, ?int $ignoreId = null): string
{
$baseSlug = Str::slug($source);
$baseSlug = $baseSlug !== '' ? $baseSlug : 'item';
$slug = $baseSlug;
$counter = 1;
while ($this->slugExists($modelClass, $column, $slug, $ignoreId)) {
$slug = $baseSlug . '-' . $counter;
$counter++;
}
return $slug;
}
private function slugExists(string $modelClass, string $column, string $slug, ?int $ignoreId): bool
{
return $modelClass::query()
->when($ignoreId, function ($query) use ($ignoreId): void {
$query->where('id', '!=', $ignoreId);
})
->where($column, $slug)
->exists();
}
}

18
artisan Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

20
bootstrap/app.php Normal file
View File

@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'role' => \App\Http\Middleware\RoleMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

7
bootstrap/providers.php Normal file
View File

@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];

85
composer.json Normal file
View File

@ -0,0 +1,85 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8153
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

BIN
composer.phar Normal file

Binary file not shown.

126
config/app.php Normal file
View File

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

117
config/auth.php Normal file
View File

@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

130
config/cache.php Normal file
View File

@ -0,0 +1,130 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

184
config/database.php Normal file
View File

@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

80
config/filesystems.php Normal file
View File

@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

132
config/logging.php Normal file
View File

@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View File

@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];

View File

@ -0,0 +1,53 @@
<?php
return [
'transfer_bank_bca' => [
'label' => 'Transfer Bank BCA',
'group' => 'bank',
'account_name' => 'PT Velora Mobilitas Nusantara',
'account_number' => '1234567890',
'instruction' => 'Transfer ke rekening BCA resmi kami lalu upload bukti pembayaran.',
],
'transfer_bank_bni' => [
'label' => 'Transfer Bank BNI',
'group' => 'bank',
'account_name' => 'PT Velora Mobilitas Nusantara',
'account_number' => '9876543210',
'instruction' => 'Transfer ke rekening BNI resmi kami lalu upload bukti pembayaran.',
],
'transfer_bank_bri' => [
'label' => 'Transfer Bank BRI',
'group' => 'bank',
'account_name' => 'PT Velora Mobilitas Nusantara',
'account_number' => '1122334455',
'instruction' => 'Transfer ke rekening BRI resmi kami lalu upload bukti pembayaran.',
],
'transfer_bank_mandiri' => [
'label' => 'Transfer Bank Mandiri',
'group' => 'bank',
'account_name' => 'PT Velora Mobilitas Nusantara',
'account_number' => '5566778899',
'instruction' => 'Transfer ke rekening Mandiri resmi kami lalu upload bukti pembayaran.',
],
'ewallet_ovo' => [
'label' => 'E-Wallet OVO',
'group' => 'ewallet',
'account_name' => 'PT Velora Mobilitas Nusantara',
'account_number' => 'OVO 081234567890',
'instruction' => 'Bayar melalui OVO ke tujuan resmi kami lalu upload bukti pembayaran.',
],
'ewallet_gopay' => [
'label' => 'E-Wallet GoPay',
'group' => 'ewallet',
'account_name' => 'PT Velora Mobilitas Nusantara',
'account_number' => 'GoPay 081234567890',
'instruction' => 'Bayar melalui GoPay ke tujuan resmi kami lalu upload bukti pembayaran.',
],
'ewallet_dana' => [
'label' => 'E-Wallet DANA',
'group' => 'ewallet',
'account_name' => 'PT Velora Mobilitas Nusantara',
'account_number' => 'DANA 081234567890',
'instruction' => 'Bayar melalui DANA ke tujuan resmi kami lalu upload bukti pembayaran.',
],
];

5
config/platform.php Normal file
View File

@ -0,0 +1,5 @@
<?php
return [
'commission_percentage' => 10,
];

129
config/queue.php Normal file
View File

@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

Some files were not shown because too many files have changed in this diff Show More