583 lines
15 KiB
PHP
583 lines
15 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
|
||
class Kontrakan extends Model
|
||
{
|
||
use HasFactory;
|
||
|
||
/**
|
||
* Boot method to handle model events.
|
||
*/
|
||
protected static function boot()
|
||
{
|
||
parent::boot();
|
||
|
||
static::deleting(function ($kontrakan) {
|
||
// Hapus foto utama jika ada
|
||
if ($kontrakan->foto) {
|
||
$paths = [
|
||
public_path('uploads/kontrakan/' . $kontrakan->foto),
|
||
public_path('uploads/Kontrakan/' . $kontrakan->foto)
|
||
];
|
||
foreach ($paths as $path) {
|
||
if (file_exists($path)) {
|
||
@unlink($path);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Hapus semua foto di galeri beserta file fisiknya
|
||
if ($kontrakan->galeri) {
|
||
foreach ($kontrakan->galeri as $g) {
|
||
$filePath = public_path('uploads/galeri/kontrakan/' . $g->foto);
|
||
if (file_exists($filePath)) {
|
||
@unlink($filePath);
|
||
}
|
||
$g->delete();
|
||
}
|
||
}
|
||
|
||
// Hapus reviews terkait
|
||
$kontrakan->reviews()->delete();
|
||
|
||
// Hapus favorites terkait
|
||
$kontrakan->favorites()->delete();
|
||
});
|
||
}
|
||
|
||
protected $fillable = [
|
||
'admin_id',
|
||
'nama',
|
||
'alamat',
|
||
'no_whatsapp',
|
||
'latitude',
|
||
'longitude',
|
||
'harga',
|
||
'jarak',
|
||
'fasilitas',
|
||
'jumlah_kamar',
|
||
'bathroom_count',
|
||
'foto',
|
||
'status',
|
||
'occupied_until',
|
||
'availability_confirmed_at',
|
||
];
|
||
|
||
protected $casts = [
|
||
'occupied_until' => 'date',
|
||
'availability_confirmed_at' => 'datetime',
|
||
];
|
||
|
||
/**
|
||
* Tambahkan foto_url ke semua JSON response secara otomatis
|
||
*/
|
||
protected $appends = ['foto_url'];
|
||
|
||
/**
|
||
* Accessor: foto_url = full absolute URL foto utama
|
||
* Mengecek keberadaan file di disk agar URL selalu benar,
|
||
* terlepas dari casing nama folder (kontrakan vs Kontrakan).
|
||
*/
|
||
public function getFotoUrlAttribute(): ?string
|
||
{
|
||
if (empty($this->foto)) {
|
||
return null;
|
||
}
|
||
|
||
$foto = $this->foto;
|
||
|
||
if (str_starts_with($foto, 'http')) {
|
||
return $foto;
|
||
}
|
||
|
||
// Cek keberadaan file di tiap kemungkinan folder
|
||
$folders = ['uploads/kontrakan', 'uploads/Kontrakan'];
|
||
foreach ($folders as $folder) {
|
||
if (file_exists(public_path($folder . '/' . $foto))) {
|
||
return url($folder . '/' . $foto);
|
||
}
|
||
}
|
||
|
||
// Fallback: konstruksi URL dengan folder lowercase (standar)
|
||
return url('uploads/kontrakan/' . $foto);
|
||
}
|
||
|
||
/**
|
||
* ========== RELASI ADMIN ==========
|
||
*/
|
||
|
||
/**
|
||
* Relasi: 1 Kontrakan dimiliki oleh 1 Admin
|
||
*/
|
||
public function admin()
|
||
{
|
||
return $this->belongsTo(Admin::class);
|
||
}
|
||
|
||
/**
|
||
* Generate Google Maps URL
|
||
*/
|
||
public function getMapsUrlAttribute()
|
||
{
|
||
if ($this->latitude && $this->longitude) {
|
||
return "https://www.google.com/maps?q={$this->latitude},{$this->longitude}";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* ========== FITUR BARU: WHATSAPP ==========
|
||
*/
|
||
|
||
/**
|
||
* Generate WhatsApp URL untuk chat langsung
|
||
*/
|
||
public function getWhatsappUrlAttribute()
|
||
{
|
||
if (!$this->no_whatsapp) {
|
||
return null;
|
||
}
|
||
|
||
// Format nomor WhatsApp (hilangkan karakter selain angka)
|
||
$cleanNumber = preg_replace('/[^0-9]/', '', $this->no_whatsapp);
|
||
|
||
// Jika diawali 0, ganti dengan 62 (kode negara Indonesia)
|
||
if (substr($cleanNumber, 0, 1) === '0') {
|
||
$cleanNumber = '62' . substr($cleanNumber, 1);
|
||
}
|
||
|
||
// Jika belum ada kode negara, tambahkan 62
|
||
if (substr($cleanNumber, 0, 2) !== '62') {
|
||
$cleanNumber = '62' . $cleanNumber;
|
||
}
|
||
|
||
// Pesan default
|
||
$message = urlencode("Halo, saya tertarik dengan kontrakan *{$this->nama}* di {$this->alamat}. Apakah masih tersedia?");
|
||
|
||
return "https://wa.me/{$cleanNumber}?text={$message}";
|
||
}
|
||
|
||
/**
|
||
* Format nomor WhatsApp untuk tampilan
|
||
* Contoh: 081234567890 → 0812-3456-7890
|
||
*/
|
||
public function getFormattedWhatsappAttribute()
|
||
{
|
||
if (!$this->no_whatsapp) {
|
||
return null;
|
||
}
|
||
|
||
$number = $this->no_whatsapp;
|
||
|
||
// Format: 08XX-XXXX-XXXX
|
||
if (strlen($number) >= 10) {
|
||
return substr($number, 0, 4) . '-' . substr($number, 4, 4) . '-' . substr($number, 8);
|
||
}
|
||
|
||
return $number;
|
||
}
|
||
|
||
/**
|
||
* Cek apakah kontrakan punya nomor WhatsApp
|
||
*/
|
||
public function hasWhatsapp()
|
||
{
|
||
return !empty($this->no_whatsapp);
|
||
}
|
||
|
||
/**
|
||
* Hitung jarak dari koordinat user (dalam kilometer)
|
||
* Menggunakan Haversine formula
|
||
*/
|
||
public function calculateDistance($userLat, $userLng)
|
||
{
|
||
if (!$this->latitude || !$this->longitude) {
|
||
return null;
|
||
}
|
||
|
||
$earthRadius = 6371; // dalam kilometer
|
||
|
||
$latFrom = deg2rad($userLat);
|
||
$lonFrom = deg2rad($userLng);
|
||
$latTo = deg2rad($this->latitude);
|
||
$lonTo = deg2rad($this->longitude);
|
||
|
||
$latDelta = $latTo - $latFrom;
|
||
$lonDelta = $lonTo - $lonFrom;
|
||
|
||
$angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) +
|
||
cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2)));
|
||
|
||
return round($angle * $earthRadius, 2); // Return dalam KM dengan 2 desimal
|
||
}
|
||
|
||
/**
|
||
* ========== RELASI GALERI ==========
|
||
*/
|
||
|
||
/**
|
||
* Relasi: 1 Kontrakan punya banyak Galeri Foto
|
||
*/
|
||
public function galeri()
|
||
{
|
||
return $this->hasMany(Galeri::class, 'item_id')
|
||
->where('type', 'kontrakan')
|
||
->orderBy('urutan');
|
||
}
|
||
|
||
/**
|
||
* Get foto primary/utama dari galeri
|
||
*/
|
||
public function fotoPrimary()
|
||
{
|
||
return $this->hasOne(Galeri::class, 'item_id')
|
||
->where('type', 'kontrakan')
|
||
->where('is_primary', true);
|
||
}
|
||
|
||
/**
|
||
* ========== RELASI REVIEWS ==========
|
||
*/
|
||
|
||
/**
|
||
* Relasi: 1 Kontrakan punya banyak Reviews
|
||
*/
|
||
public function reviews()
|
||
{
|
||
return $this->hasMany(Review::class, 'item_id')
|
||
->where('type', 'kontrakan')
|
||
->with('user')
|
||
->latest();
|
||
}
|
||
|
||
/**
|
||
* Hitung rata-rata rating
|
||
*/
|
||
public function getAverageRatingAttribute()
|
||
{
|
||
return round($this->reviews()->avg('rating') ?? 0, 1);
|
||
}
|
||
|
||
/**
|
||
* Hitung total reviews
|
||
*/
|
||
public function getTotalReviewsAttribute()
|
||
{
|
||
return $this->reviews()->count();
|
||
}
|
||
|
||
/**
|
||
* ========== RELASI FAVORITES ==========
|
||
*/
|
||
|
||
/**
|
||
* Relasi: 1 Kontrakan punya banyak Favorites
|
||
*/
|
||
public function favorites()
|
||
{
|
||
return $this->hasMany(Favorite::class, 'item_id')
|
||
->where('type', 'kontrakan');
|
||
}
|
||
|
||
/**
|
||
* Cek apakah kontrakan sudah difavoritkan oleh user tertentu
|
||
*/
|
||
public function isFavoritedBy($userId)
|
||
{
|
||
return $this->favorites()->where('user_id', $userId)->exists();
|
||
}
|
||
|
||
/**
|
||
* Hitung total favorites
|
||
*/
|
||
public function getTotalFavoritesAttribute()
|
||
{
|
||
return $this->favorites()->count();
|
||
}
|
||
|
||
/**
|
||
* ========== RELASI ACTIVITY LOGS ==========
|
||
*/
|
||
|
||
/**
|
||
* Relasi: 1 Kontrakan punya banyak Activity Logs
|
||
*/
|
||
public function activityLogs()
|
||
{
|
||
return $this->morphMany(ActivityLog::class, 'model', 'model_type', 'model_id');
|
||
}
|
||
|
||
/**
|
||
* ========== RELASI BOOKINGS ==========
|
||
*/
|
||
|
||
/**
|
||
* Relasi: 1 Kontrakan punya banyak Bookings
|
||
*/
|
||
public function bookings()
|
||
{
|
||
return $this->hasMany(Booking::class);
|
||
}
|
||
|
||
/**
|
||
* Get booking aktif saat ini (jika ada)
|
||
*/
|
||
public function activeBooking()
|
||
{
|
||
return $this->hasOne(Booking::class)
|
||
->whereIn('status', [Booking::STATUS_CONFIRMED, Booking::STATUS_CHECKED_IN])
|
||
->where('start_date', '<=', now()->toDateString())
|
||
->where('end_date', '>=', now()->toDateString())
|
||
->latest();
|
||
}
|
||
|
||
/**
|
||
* ========== HELPER METHODS: STATUS & KETERSEDIAAN ==========
|
||
*/
|
||
|
||
/**
|
||
* Cek apakah kontrakan sedang terisi (berdasarkan booking aktif)
|
||
*/
|
||
public function isCurrentlyOccupied(): bool
|
||
{
|
||
// Cek dari field status langsung
|
||
if ($this->status === 'occupied') {
|
||
// Double check occupied_until masih valid
|
||
if ($this->occupied_until && $this->occupied_until->isPast()) {
|
||
// Auto-reset jika sudah lewat
|
||
$this->update(['status' => 'available', 'occupied_until' => null]);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Cek dari booking aktif
|
||
return Booking::forKontrakan($this->id)
|
||
->currentlyActive()
|
||
->exists();
|
||
}
|
||
|
||
/**
|
||
* Cek apakah kontrakan tersedia untuk periode tertentu
|
||
*/
|
||
public function isAvailableFor($startDate, $endDate): bool
|
||
{
|
||
if ($this->status === 'maintenance') {
|
||
return false;
|
||
}
|
||
|
||
return Booking::isAvailable($this->id, $startDate, $endDate);
|
||
}
|
||
|
||
/**
|
||
* Get status label untuk UI
|
||
*/
|
||
public function getStatusLabelAttribute(): string
|
||
{
|
||
return match ($this->status) {
|
||
'available' => 'Tersedia',
|
||
'booked' => 'Dipesan',
|
||
'occupied' => 'Terisi',
|
||
'maintenance' => 'Pemeliharaan',
|
||
default => ucfirst($this->status ?? 'available'),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get status badge class untuk UI
|
||
*/
|
||
public function getStatusBadgeClassAttribute(): string
|
||
{
|
||
return match ($this->status) {
|
||
'available' => 'bg-success',
|
||
'booked' => 'bg-warning text-dark',
|
||
'occupied' => 'bg-danger',
|
||
'maintenance' => 'bg-secondary',
|
||
default => 'bg-success',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Sinkronkan status berdasarkan booking aktif
|
||
*/
|
||
public function syncStatusFromBookings(): void
|
||
{
|
||
$activeBooking = Booking::forKontrakan($this->id)
|
||
->currentlyActive()
|
||
->first();
|
||
|
||
if ($activeBooking) {
|
||
if ($activeBooking->status === Booking::STATUS_CHECKED_IN) {
|
||
$this->update([
|
||
'status' => 'occupied',
|
||
'occupied_until' => $activeBooking->end_date,
|
||
]);
|
||
} else {
|
||
$this->update([
|
||
'status' => 'booked',
|
||
'occupied_until' => $activeBooking->end_date,
|
||
]);
|
||
}
|
||
} else {
|
||
// Tidak ada booking aktif
|
||
if (in_array($this->status, ['booked', 'occupied'])) {
|
||
$this->update([
|
||
'status' => 'available',
|
||
'occupied_until' => null,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Scope: Kontrakan yang tersedia
|
||
*/
|
||
public function scopeAvailable($query)
|
||
{
|
||
return $query->where('status', 'available');
|
||
}
|
||
|
||
/**
|
||
* Scope: Kontrakan yang terisi
|
||
*/
|
||
public function scopeOccupied($query)
|
||
{
|
||
return $query->where('status', 'occupied');
|
||
}
|
||
|
||
/**
|
||
* ========== BATHROOM SCORING SYSTEM ==========
|
||
*/
|
||
|
||
/**
|
||
* Hitung skor kamar mandi berdasarkan jumlah dan rasio dengan kamar tidur
|
||
*
|
||
* Formula:
|
||
* - Base = 10 (jika bathroom_count >= 1)
|
||
* - Extra = 5 × (bathroom_count - 1)
|
||
* - Ratio bonus berdasarkan bathroom-to-bedroom ratio (BBR):
|
||
* * BBR >= 1.0 → +10 poin (sangat nyaman)
|
||
* * 0.5 <= BBR < 1.0 → +5 poin (standar)
|
||
* * BBR < 0.5 → +0 poin (terbatas)
|
||
*
|
||
* @return int
|
||
*/
|
||
public function getBathroomScoreAttribute()
|
||
{
|
||
$bathroomCount = $this->bathroom_count ?? 0;
|
||
$bedroomCount = max(1, $this->jumlah_kamar ?? 1);
|
||
|
||
// Jika tidak ada kamar mandi
|
||
if ($bathroomCount == 0) {
|
||
return 0;
|
||
}
|
||
|
||
// Base score
|
||
$score = 10;
|
||
|
||
// Extra points untuk setiap kamar mandi tambahan
|
||
if ($bathroomCount > 1) {
|
||
$score += 5 * ($bathroomCount - 1);
|
||
}
|
||
|
||
// Ratio bonus
|
||
$bbr = $bathroomCount / $bedroomCount;
|
||
|
||
if ($bbr >= 1.0) {
|
||
$score += 10; // Sangat nyaman - setiap kamar punya akses kamar mandi
|
||
} elseif ($bbr >= 0.5) {
|
||
$score += 5; // Standar
|
||
}
|
||
// else: tidak ada bonus untuk BBR < 0.5
|
||
|
||
return $score;
|
||
}
|
||
|
||
/**
|
||
* Get label untuk skor kamar mandi
|
||
*
|
||
* @return string
|
||
*/
|
||
public function getBathroomLabelAttribute()
|
||
{
|
||
$score = $this->bathroom_score;
|
||
|
||
return match(true) {
|
||
$score == 0 => 'Tidak ada kamar mandi',
|
||
$score <= 10 => 'Terbatas',
|
||
$score <= 20 => 'Standar',
|
||
$score <= 30 => 'Nyaman',
|
||
default => 'Sangat Nyaman'
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get badge class untuk skor kamar mandi
|
||
*
|
||
* @return string
|
||
*/
|
||
public function getBathroomBadgeClassAttribute()
|
||
{
|
||
$score = $this->bathroom_score;
|
||
|
||
return match(true) {
|
||
$score == 0 => 'bg-danger',
|
||
$score <= 10 => 'bg-warning',
|
||
$score <= 20 => 'bg-info',
|
||
$score <= 30 => 'bg-primary',
|
||
default => 'bg-success'
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get icon untuk skor kamar mandi
|
||
*
|
||
* @return string
|
||
*/
|
||
public function getBathroomIconAttribute()
|
||
{
|
||
$score = $this->bathroom_score;
|
||
|
||
return match(true) {
|
||
$score == 0 => 'bi-x-circle',
|
||
$score <= 10 => 'bi-droplet-half',
|
||
$score <= 20 => 'bi-droplet',
|
||
$score <= 30 => 'bi-stars',
|
||
default => 'bi-gem'
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get deskripsi detail fasilitas kamar mandi
|
||
*
|
||
* @return string
|
||
*/
|
||
public function getBathroomDescriptionAttribute()
|
||
{
|
||
$bathroomCount = $this->bathroom_count ?? 0;
|
||
$bedroomCount = $this->jumlah_kamar ?? 1;
|
||
$bbr = $bathroomCount > 0 ? round($bathroomCount / $bedroomCount, 2) : 0;
|
||
|
||
if ($bathroomCount == 0) {
|
||
return 'Tidak ada kamar mandi';
|
||
}
|
||
|
||
if ($bathroomCount == 1) {
|
||
return '1 kamar mandi';
|
||
}
|
||
|
||
$description = "{$bathroomCount} kamar mandi";
|
||
|
||
// Tambahkan keterangan jika rasionya bagus
|
||
if ($bbr >= 1.0) {
|
||
$description .= ' — Setiap kamar punya akses kamar mandi';
|
||
}
|
||
|
||
return $description;
|
||
}
|
||
}
|