Initial Commit
This commit is contained in:
commit
6db3c09f78
|
|
@ -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
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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])],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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])],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, ',', '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
|
||||||
|
return [
|
||||||
|
AppServiceProvider::class,
|
||||||
|
];
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -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),
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -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')),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -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.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'commission_percentage' => 10,
|
||||||
|
];
|
||||||
|
|
@ -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
Loading…
Reference in New Issue