feat: tampilkan jam_survei di admin list dan detail booking, tambah fillable jam_survei di model

This commit is contained in:
micko samawa 2026-06-29 08:59:06 +07:00
parent 0c29575843
commit ca476cb8e6
11 changed files with 442 additions and 50 deletions

View File

@ -58,80 +58,169 @@ public function show(Request $request, $id)
}
/**
* Create booking baru
* Create pengajuan baru (survei atau sewa) dari mobile
*/
public function store(Request $request)
{
$jenis = $request->input('jenis_pengajuan', 'sewa');
if ($jenis === 'survei') {
return $this->storeSurvei($request);
}
return $this->storeSewa($request);
}
/**
* Simpan pengajuan survei
*/
private function storeSurvei(Request $request)
{
$validator = Validator::make($request->all(), [
'kontrakan_id' => 'required|exists:kontrakans,id',
'tanggal_survei' => 'required|date|after:today',
'jam_survei' => 'required|string',
'catatan' => 'nullable|string|max:1000',
], [
'kontrakan_id.required' => 'Kontrakan wajib dipilih',
'tanggal_survei.required' => 'Tanggal survei wajib diisi',
'tanggal_survei.after' => 'Tanggal survei harus setelah hari ini',
'jam_survei.required' => 'Jam survei wajib diisi',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validasi gagal',
'error_code' => 'VALIDATION_ERROR',
'errors' => $validator->errors(),
], 422);
}
$kontrakan = Kontrakan::find($request->kontrakan_id);
if (!$kontrakan) {
return response()->json([
'success' => false,
'message' => 'Kontrakan tidak ditemukan',
'error_code' => 'NOT_FOUND',
], 404);
}
// Untuk survei, kontrakan cukup tersedia (tidak occupied)
if ($kontrakan->status === 'occupied') {
return response()->json([
'success' => false,
'message' => 'Kontrakan sedang ditempati',
'error_code' => 'NOT_AVAILABLE',
], 400);
}
$tanggalSurvei = Carbon::parse($request->tanggal_survei);
$booking = Booking::create([
'user_id' => $request->user()->id,
'kontrakan_id' => $request->kontrakan_id,
'jenis_pengajuan' => 'survei',
'tanggal_survei' => $tanggalSurvei,
'jam_survei' => $request->jam_survei,
'start_date' => $tanggalSurvei,
'end_date' => $tanggalSurvei,
'amount' => 0,
'status' => 'pending',
'payment_status' => 'unpaid',
'booking_source' => 'user',
'notes' => $request->catatan,
]);
return response()->json([
'success' => true,
'message' => 'Pengajuan survei berhasil dikirim. Tunggu konfirmasi dari pemilik.',
'data' => $booking->load('kontrakan'),
], 201);
}
/**
* Simpan pengajuan sewa
*/
private function storeSewa(Request $request)
{
$validator = Validator::make($request->all(), [
'kontrakan_id' => 'required|exists:kontrakans,id',
'tanggal_mulai' => 'required|date|after:today',
'durasi_bulan' => 'required|integer|min:1|max:12',
'catatan' => 'nullable|string',
'payment_proof' => 'required|image|mimes:jpeg,jpg,png|max:5120',
'catatan' => 'nullable|string|max:1000',
'ktp_photo' => 'nullable|image|mimes:jpeg,jpg,png|max:5120',
], [
'payment_proof.required' => 'Bukti pembayaran wajib diunggah',
'payment_proof.image' => 'File harus berupa gambar',
'payment_proof.mimes' => 'Format file harus jpeg, jpg, atau png',
'payment_proof.max' => 'Ukuran file maksimal 5MB',
'kontrakan_id.required' => 'Kontrakan wajib dipilih',
'tanggal_mulai.required' => 'Tanggal mulai sewa wajib diisi',
'tanggal_mulai.after' => 'Tanggal mulai harus setelah hari ini',
'durasi_bulan.required' => 'Durasi sewa wajib diisi',
'ktp_photo.image' => 'Foto KTP harus berupa gambar',
'ktp_photo.mimes' => 'Format KTP harus jpeg, jpg, atau png',
'ktp_photo.max' => 'Ukuran foto KTP maksimal 5MB',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validasi gagal',
'success' => false,
'message' => 'Validasi gagal',
'error_code' => 'VALIDATION_ERROR',
'errors' => $validator->errors()
'errors' => $validator->errors(),
], 422);
}
// Check kontrakan availability
$kontrakan = Kontrakan::find($request->kontrakan_id);
// Handle both status values: 'tersedia' and 'available'
if (!$kontrakan) {
return response()->json([
'success' => false,
'message' => 'Kontrakan tidak ditemukan',
'error_code' => 'NOT_FOUND',
], 404);
}
// Cek ketersediaan kontrakan
if (!in_array($kontrakan->status, ['tersedia', 'available'])) {
return response()->json([
'success' => false,
'message' => 'Kontrakan tidak tersedia',
'success' => false,
'message' => 'Kontrakan tidak tersedia untuk disewa',
'error_code' => 'NOT_AVAILABLE',
], 400);
}
// Calculate tanggal selesai
$startDate = Carbon::parse($request->tanggal_mulai);
$endDate = $startDate->copy()->addMonths((int)$request->durasi_bulan);
// Calculate total biaya from annual price pro-rated to monthly duration
$amount = $kontrakan->harga * ((int)$request->durasi_bulan / 12);
$endDate = $startDate->copy()->addMonths((int)$request->durasi_bulan);
$amount = $kontrakan->harga * ((int)$request->durasi_bulan / 12);
$bookingData = [
'user_id' => $request->user()->id,
'kontrakan_id' => $request->kontrakan_id,
'start_date' => $startDate,
'end_date' => $endDate,
'amount' => $amount,
'status' => 'pending',
'notes' => $request->catatan,
'user_id' => $request->user()->id,
'kontrakan_id' => $request->kontrakan_id,
'jenis_pengajuan' => 'sewa',
'start_date' => $startDate,
'end_date' => $endDate,
'amount' => $amount,
'status' => 'pending',
'payment_status' => 'unpaid',
'booking_source' => 'user',
'notes' => $request->catatan,
];
// Handle payment proof upload
if ($request->hasFile('payment_proof')) {
// ✅ Store sensitive payment proof in PRIVATE storage
$path = $request->file('payment_proof')->store(self::PAYMENT_PROOF_DIR, self::PAYMENT_PROOF_PRIVATE_DISK);
$bookingData['payment_proof'] = $path;
$bookingData['payment_status'] = 'paid';
$bookingData['payment_method'] = 'transfer';
$bookingData['paid_at'] = now();
// Upload foto KTP jika disertakan
if ($request->hasFile('ktp_photo')) {
$ktpPath = $request->file('ktp_photo')->store('ktp_photos', self::PAYMENT_PROOF_PRIVATE_DISK);
$bookingData['ktp_photo'] = $ktpPath;
}
$booking = Booking::create($bookingData);
// Update status kontrakan ke booked saat ada booking masuk
// Update status kontrakan ke booked
$kontrakan->update(['status' => 'booked']);
return response()->json([
'success' => true,
'message' => 'Booking berhasil dibuat',
'data' => $booking->load('kontrakan')
'message' => 'Pengajuan sewa berhasil dikirim. Tunggu persetujuan pemilik.',
'data' => $booking->load('kontrakan'),
], 201);
}
@ -279,10 +368,12 @@ public function uploadPaymentProof(Request $request, $id)
], 400);
}
if ($booking->payment_status === 'paid') {
if (in_array($booking->payment_status, ['paid', 'verification'])) {
return response()->json([
'success' => false,
'message' => 'Pembayaran booking ini sudah dikonfirmasi',
'success' => false,
'message' => $booking->payment_status === 'paid'
? 'Pembayaran booking ini sudah dikonfirmasi'
: 'Bukti pembayaran sudah diunggah dan sedang menunggu verifikasi admin',
'error_code' => 'ALREADY_PAID',
], 400);
}
@ -298,17 +389,17 @@ public function uploadPaymentProof(Request $request, $id)
// ✅ Store sensitive payment proof in PRIVATE storage
$path = $request->file('payment_proof')->store(self::PAYMENT_PROOF_DIR, self::PAYMENT_PROOF_PRIVATE_DISK);
// Update booking
// Update booking status jadi 'verification' agar admin bisa memverifikasi
$booking->update([
'payment_proof' => $path,
'payment_status' => 'paid',
'payment_status' => 'verification',
'payment_method' => 'transfer',
'paid_at' => now(),
'payment_rejection_reason' => null,
]);
return response()->json([
'success' => true,
'message' => 'Bukti pembayaran berhasil diunggah. Pembayaran Anda telah dikonfirmasi.',
'message' => 'Bukti pembayaran berhasil diunggah. Menunggu verifikasi admin.',
'data' => $booking->fresh()->load('kontrakan'),
], 200);
}

View File

@ -594,6 +594,45 @@ public function verifyPayment(Request $request, Booking $booking)
return back()->with('success', 'Pembayaran berhasil diverifikasi dan ditandai lunas.');
}
/**
* Tolak verifikasi pembayaran (status verification -> unpaid)
*/
public function rejectPayment(Request $request, Booking $booking)
{
// ========== AUTHORIZATION ===========
$admin = auth()->guard('admin')->user();
if ($admin) {
if ($booking->kontrakan->admin_id !== $admin->id) {
abort(403, 'Anda tidak memiliki akses ke booking ini.');
}
}
// Only allow rejection when status is verification
if ($booking->payment_status !== 'verification') {
return back()->with('error', 'Status pembayaran bukan verifikasi.');
}
$request->validate([
'rejection_reason' => 'required|string|max:1000',
], [
'rejection_reason.required' => 'Alasan penolakan wajib diisi.',
]);
// Hapus file bukti pembayaran yang salah/tidak valid
if ($booking->payment_proof) {
Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->delete($booking->payment_proof);
Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->delete($booking->payment_proof);
}
$booking->update([
'payment_status' => 'unpaid',
'payment_proof' => null,
'payment_rejection_reason' => $request->rejection_reason,
]);
return back()->with('success', 'Pembayaran ditolak. Penyewa telah diinformasikan untuk mengunggah ulang.');
}
/**
* Hapus booking
*/

View File

@ -78,6 +78,9 @@ public static function syncKontrakanStatus($kontrakanId)
'checked_out_at',
'cancelled_at',
'cancellation_reason',
'payment_rejection_reason',
'jam_survei',
'tanggal_survei',
];
protected $casts = [

View File

@ -9,6 +9,46 @@ 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',

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
// Jenis pengajuan: survei atau sewa
if (!Schema::hasColumn('bookings', 'jenis_pengajuan')) {
$table->string('jenis_pengajuan')->default('sewa')->after('notes');
}
// Tanggal dan jam survei (hanya untuk jenis_pengajuan = survei)
if (!Schema::hasColumn('bookings', 'tanggal_survei')) {
$table->date('tanggal_survei')->nullable()->after('jenis_pengajuan');
}
if (!Schema::hasColumn('bookings', 'jam_survei')) {
$table->string('jam_survei')->nullable()->after('tanggal_survei');
}
// Foto KTP (untuk pengajuan sewa)
if (!Schema::hasColumn('bookings', 'ktp_photo')) {
$table->string('ktp_photo')->nullable()->after('jam_survei');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn(['jenis_pengajuan', 'tanggal_survei', 'jam_survei', 'ktp_photo']);
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
if (!Schema::hasColumn('bookings', 'payment_rejection_reason')) {
$table->text('payment_rejection_reason')->nullable()->after('payment_proof');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn('payment_rejection_reason');
});
}
};

View File

@ -172,10 +172,23 @@
<small class="text-muted">{{ $booking->tenant_phone }}</small>
</td>
<td>
<div>{{ $booking->start_date->format('d M Y') }}</div>
<small class="text-muted">s/d {{ $booking->end_date->format('d M Y') }}</small>
<br>
<span class="badge bg-light text-dark">{{ $booking->duration_days }} hari</span>
@if($booking->jenis_pengajuan === 'survei')
<div class="fw-semibold text-success">
<i class="bi bi-calendar-event me-1"></i>{{ $booking->start_date->format('d M Y') }}
</div>
@if($booking->jam_survei)
<small class="text-muted">
<i class="bi bi-clock me-1"></i>Pukul {{ $booking->jam_survei }} WIB
</small>
@endif
<br>
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25">Survei</span>
@else
<div>{{ $booking->start_date->format('d M Y') }}</div>
<small class="text-muted">s/d {{ $booking->end_date->format('d M Y') }}</small>
<br>
<span class="badge bg-light text-dark">{{ $booking->duration_days }} hari</span>
@endif
</td>
<td>
<span class="fw-semibold">Rp {{ number_format($booking->amount, 0, ',', '.') }}</span>
@ -200,6 +213,9 @@
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Verifikasi pembayaran?')">
Verifikasi
</button>
<button type="button" class="btn btn-sm btn-danger ms-1" data-bs-toggle="modal" data-bs-target="#rejectPaymentModal" data-action="{{ route('admin.bookings.reject-payment', $booking->id) }}">
Tolak
</button>
</form>
@endif
@if($booking->payment_proof)
@ -278,11 +294,49 @@
@endif
</div>
</div>
{{-- Reject Payment Modal --}}
<div class="modal fade" id="rejectPaymentModal" tabindex="-1" aria-labelledby="rejectPaymentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form id="rejectPaymentForm" method="POST">
@csrf
<div class="modal-header">
<h5 class="modal-title fw-bold" id="rejectPaymentModalLabel">Tolak Verifikasi Pembayaran</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="rejection_reason" class="form-label">Alasan Penolakan</label>
<textarea class="form-control" id="rejection_reason" name="rejection_reason" rows="3" required placeholder="Masukkan alasan penolakan (misal: Bukti pembayaran tidak valid/kurang jelas)"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Batal</button>
<button type="submit" class="btn btn-danger">Tolak Pembayaran</button>
</div>
</form>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
// Handle Reject Payment Modal action URL
const rejectPaymentModal = document.getElementById('rejectPaymentModal');
if (rejectPaymentModal) {
rejectPaymentModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const actionUrl = button.getAttribute('data-action');
const form = document.getElementById('rejectPaymentForm');
if (form) {
form.setAttribute('action', actionUrl);
}
});
}
const selectAll = document.getElementById('selectAllBookings');
const checkboxes = Array.from(document.querySelectorAll('.booking-checkbox'));
const selectableCheckboxes = checkboxes.filter(item => !item.disabled);

View File

@ -192,11 +192,24 @@ class="rounded me-3" style="width: 120px; height: 90px; object-fit: cover;">
</div>
<div class="card-body">
<div class="mb-3">
@if($booking->jenis_pengajuan === 'survei')
<label class="text-muted small">Jadwal Survei</label>
<div class="fw-semibold">
<i class="bi bi-calendar-event me-1 text-success"></i>{{ $booking->start_date->format('d M Y') }}
</div>
@if($booking->jam_survei)
<div class="fw-semibold mt-1">
<i class="bi bi-clock me-1 text-success"></i>Pukul {{ $booking->jam_survei }} WIB
</div>
@endif
<span class="badge bg-success mt-1">Survei</span>
@else
<label class="text-muted small">Periode Sewa</label>
<div class="fw-semibold">
{{ $booking->start_date->format('d M Y') }} - {{ $booking->end_date->format('d M Y') }}
</div>
<span class="badge bg-light text-dark">{{ $booking->duration_days }} hari ({{ $booking->duration_months }} bulan)</span>
@endif
</div>
<hr>
<div class="d-flex justify-content-between mb-2">
@ -304,6 +317,9 @@ class="btn btn-sm btn-outline-success mt-1">
</button>
</div>
</form>
<button type="button" class="btn btn-danger w-100 mt-2" data-bs-toggle="modal" data-bs-target="#rejectPaymentModal">
<i class="bi bi-x-circle me-1"></i>Tolak Pembayaran
</button>
@endif
@endif
@ -361,4 +377,29 @@ class="btn btn-sm btn-outline-success mt-1">
</div>
</div>
@endif
{{-- Reject Payment Modal --}}
<div class="modal fade" id="rejectPaymentModal" tabindex="-1" aria-labelledby="rejectPaymentModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ route('admin.bookings.reject-payment', $booking->id) }}" method="POST">
@csrf
<div class="modal-header">
<h5 class="modal-title fw-bold" id="rejectPaymentModalLabel">Tolak Verifikasi Pembayaran</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="rejection_reason" class="form-label">Alasan Penolakan</label>
<textarea class="form-control" id="rejection_reason" name="rejection_reason" rows="3" required placeholder="Masukkan alasan penolakan (misal: Bukti pembayaran tidak valid/kurang jelas)"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Batal</button>
<button type="submit" class="btn btn-danger">Tolak Pembayaran</button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@ -147,6 +147,7 @@
Route::post('/{booking}/mark-paid', [BookingController::class, 'markPaid'])->name('mark-paid');
Route::post('/{booking}/toggle-payment', [BookingController::class, 'togglePaymentStatus'])->name('toggle-payment');
Route::post('/{booking}/verify-payment', [BookingController::class, 'verifyPayment'])->name('verify-payment');
Route::post('/{booking}/reject-payment', [BookingController::class, 'rejectPayment'])->name('reject-payment');
// API & History
Route::post('/check-availability', [BookingController::class, 'checkAvailability'])->name('check-availability');

View File

@ -1,4 +1,4 @@
class Booking {
class Booking {
final int id;
final int userId;
final int kontrakanId;
@ -18,6 +18,7 @@
// Pembayaran hanya digunakan pada pengajuan sewa.
final String paymentStatus;
final String? paymentProof;
final String? paymentRejectionReason;
Booking({
required this.id,
@ -35,6 +36,7 @@
this.surveyFollowUpExpiresAt,
this.paymentStatus = 'unpaid',
this.paymentProof,
this.paymentRejectionReason,
});
static DateTime? _parseDate(dynamic value) {
@ -75,6 +77,7 @@
),
paymentStatus: json['payment_status']?.toString() ?? 'unpaid',
paymentProof: json['payment_proof']?.toString(),
paymentRejectionReason: json['payment_rejection_reason']?.toString(),
);
}

View File

@ -1073,6 +1073,54 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
),
),
],
if (booking.paymentRejectionReason != null &&
booking.paymentRejectionReason!.isNotEmpty) ...[
Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 14),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFFF1F0),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFFA39E)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.error_outline_rounded,
color: Color(0xFFF5222D),
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pembayaran Ditolak',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Color(0xFFF5222D),
),
),
const SizedBox(height: 4),
Text(
booking.paymentRejectionReason!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF8C1D1D),
height: 1.35,
),
),
],
),
),
],
),
),
],
if (booking.canUploadPaymentProof) ...[
const SizedBox(height: 14),
SizedBox(