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