diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b11ae4a..9521e60 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,7 +19,8 @@ "Bash(php -r ':*)", "Bash(php -r \"echo file_put_contents\\('C:/xampp/htdocs/scp-syria/storage/app/public/test.txt', 'ok'\\) ? 'write OK' : 'write FAIL';\")", "Bash(curl:*)", - "Bash(php:*)" + "Bash(php:*)", + "Bash(grep -v \"^$\")" ] } } diff --git a/app/Http/Controllers/Admin/BookingController.php b/app/Http/Controllers/Admin/BookingController.php index 23029fa..c5f078c 100644 --- a/app/Http/Controllers/Admin/BookingController.php +++ b/app/Http/Controllers/Admin/BookingController.php @@ -47,34 +47,75 @@ class BookingController extends Controller )); } - public function completeBooking(Request $request, Booking $booking): JsonResponse + public function checkoutPreview(Booking $booking): JsonResponse { if ($booking->status !== 'active') { - return response()->json([ - 'success' => false, - 'message' => 'الحجز غير نشط' - ], 400); + return response()->json(['success' => false, 'message' => 'الحجز غير نشط'], 400); } - $parkingLot = $booking->parkingLot; - $actualDurationHours = $booking->start_time->diffInHours(now()); - $actualFee = ceil($actualDurationHours) * $parkingLot->price_per_hour; - - $booking->update([ - 'status' => 'completed', - 'end_time' => now() - ]); + $lot = $booking->parkingLot; + $start = $booking->start_time; + $end = now(); + $duration = $start->diffInMinutes($end); + $calc = $lot->calculateFee($start, $end, $booking->pricing_snapshot); return response()->json([ 'success' => true, - 'message' => 'تم إنهاء الحجز بنجاح', - 'data' => [ - 'actual_duration' => $actualDurationHours . ' ساعات', - 'actual_fee' => number_format($actualFee, 2) . ' ' . config('app.currency', 'ريال'), - 'vehicle_plate' => $booking->vehicle_plate ?? $booking->customer_name - ] + 'data' => [ + 'plate' => $booking->vehicle_plate, + 'customer_name' => $booking->customer_name, + 'lot_name' => $lot->name, + 'entry_time' => $start->format('Y/m/d H:i'), + 'exit_time' => $end->format('Y/m/d H:i'), + 'duration_label' => floor($duration / 60) . 'س ' . ($duration % 60) . 'د', + 'fee_details' => $calc['details'], + 'total_fee' => $calc['total'], + ], ]); } + + public function completeBooking(Request $request, Booking $booking): JsonResponse + { + if ($booking->status !== 'active') { + return response()->json(['success' => false, 'message' => 'الحجز غير نشط'], 400); + } + + $end = now(); + $type = $request->input('type', 'force'); // 'payment' | 'force' + + if ($type === 'payment') { + $request->validate([ + 'payment_method' => 'required|in:cash,upload', + 'payment_proof' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:4096', + ]); + + $lot = $booking->parkingLot; + $calc = $lot->calculateFee($booking->start_time, $end, $booking->pricing_snapshot); + + $proofPath = null; + if ($request->hasFile('payment_proof')) { + $proofPath = $request->file('payment_proof')->store('payment_proofs', 'public'); + } + + $booking->update([ + 'status' => 'completed', + 'end_time' => $end, + 'total_fee' => $calc['total'], + 'payment_method' => $request->payment_method, + 'payment_proof' => $proofPath, + 'paid_at' => $end, + ]); + } else { + // Force close — car left without paying + $booking->update([ + 'status' => 'completed', + 'end_time' => $end, + 'notes' => $request->input('notes') ?: null, + ]); + } + + return response()->json(['success' => true, 'message' => 'تم إنهاء الحجز بنجاح']); + } } ?> diff --git a/app/Http/Controllers/Operator/OperatorController.php b/app/Http/Controllers/Operator/OperatorController.php index d7c64d0..71f6b49 100644 --- a/app/Http/Controllers/Operator/OperatorController.php +++ b/app/Http/Controllers/Operator/OperatorController.php @@ -191,6 +191,28 @@ class OperatorController extends Controller ]); } + // ── Cancel a pre-reservation (operator verifies name or phone) ────────────── + + public function cancelReservation(Request $request, Booking $booking): JsonResponse + { + if ($booking->source !== 'reservation' || $booking->status !== 'active') { + return response()->json(['success' => false, 'message' => 'هذا الحجز لا يمكن إلغاؤه'], 400); + } + + $input = trim($request->input('verification', '')); + + $nameMatch = $booking->customer_name && mb_strtolower($input) === mb_strtolower($booking->customer_name); + $phoneMatch = $booking->phone && $input === $booking->phone; + + if (!$nameMatch && !$phoneMatch) { + return response()->json(['success' => false, 'message' => 'الاسم أو رقم الهاتف غير مطابق'], 422); + } + + $booking->update(['status' => 'cancelled']); + + return response()->json(['success' => true, 'message' => 'تم إلغاء الحجز بنجاح']); + } + // ── Legacy checkout (kept for backward compatibility) ─────────────────────── public function checkOut(Request $request, Booking $booking): JsonResponse diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index 86ba32d..84253fb 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -81,6 +81,7 @@ class ProfileController extends Controller 'customer_name' => $user->name, 'phone' => $user->phone ?? '', 'vehicle_plate' => $request->vehicle_plate, + 'source' => 'reservation', 'start_time' => $request->start_time, 'end_time' => $request->end_time, 'status' => 'active', @@ -101,6 +102,12 @@ class ProfileController extends Controller ->latest() ->get(); + $pendingDebt = $bookings + ->where('status', 'cancelled') + ->where('total_fee', '>', 0) + ->filter(fn($b) => is_null($b->paid_at)) + ->sum('total_fee'); + $stats = [ 'total' => $bookings->count(), 'active' => $bookings->where('status', 'active')->count(), @@ -108,6 +115,110 @@ class ProfileController extends Controller 'cancelled' => $bookings->where('status', 'cancelled')->count(), ]; - return view('user.dashboard', compact('bookings', 'stats')); + return view('user.dashboard', compact('bookings', 'stats', 'pendingDebt')); + } + + // ── Cancel preview — returns fee (or free) ──────────────────────────────── + + public function cancelPreview(Booking $booking): \Illuminate\Http\JsonResponse + { + if ($booking->user_id !== Auth::id()) { + return response()->json(['success' => false, 'message' => 'غير مصرح'], 403); + } + if ($booking->status !== 'active' || $booking->source !== 'reservation') { + return response()->json(['success' => false, 'message' => 'لا يمكن إلغاء هذا الحجز'], 400); + } + + $isFree = now()->lt($booking->start_time); + + if ($isFree) { + return response()->json([ + 'success' => true, + 'data' => ['is_free' => true, 'fee' => 0, 'fee_details' => []], + ]); + } + + $lot = $booking->parkingLot; + $calcEnd = now()->lt($booking->end_time) ? now() : $booking->end_time; + $calc = $lot->calculateFee($booking->start_time, $calcEnd, $booking->pricing_snapshot); + + return response()->json([ + 'success' => true, + 'data' => [ + 'is_free' => false, + 'fee' => $calc['total'], + 'fee_details' => $calc['details'], + 'entry_time' => $booking->start_time->format('Y/m/d H:i'), + 'cancel_time' => now()->format('Y/m/d H:i'), + ], + ]); + } + + // ── Process cancellation ────────────────────────────────────────────────── + + public function cancelBooking(Request $request, Booking $booking): \Illuminate\Http\JsonResponse + { + if ($booking->user_id !== Auth::id()) { + return response()->json(['success' => false, 'message' => 'غير مصرح'], 403); + } + if ($booking->status !== 'active' || $booking->source !== 'reservation') { + return response()->json(['success' => false, 'message' => 'لا يمكن إلغاء هذا الحجز'], 400); + } + + $isFree = now()->lt($booking->start_time); + $end = now(); + + if ($isFree) { + $booking->update(['status' => 'cancelled', 'total_fee' => 0, 'end_time' => $end]); + return response()->json(['success' => true, 'message' => 'تم إلغاء الحجز مجاناً']); + } + + // After start time — fee applies + $lot = $booking->parkingLot; + $calcEnd = $end->lt($booking->end_time) ? $end : $booking->end_time; + $calc = $lot->calculateFee($booking->start_time, $calcEnd, $booking->pricing_snapshot); + $type = $request->input('type'); // 'pay_now' | 'pay_later' + + if ($type === 'pay_now') { + $request->validate(['payment_method' => 'required|in:cash,upload']); + $proofPath = null; + if ($request->hasFile('payment_proof')) { + $proofPath = $request->file('payment_proof')->store('payment_proofs', 'public'); + } + $booking->update([ + 'status' => 'cancelled', + 'end_time' => $end, + 'total_fee' => $calc['total'], + 'payment_method' => $request->payment_method, + 'payment_proof' => $proofPath, + 'paid_at' => $end, + ]); + return response()->json(['success' => true, 'message' => 'تم إلغاء الحجز وتسجيل الدفع']); + } + + // Pay later — record fee as debt + $booking->update([ + 'status' => 'cancelled', + 'end_time' => $end, + 'total_fee' => $calc['total'], + ]); + return response()->json([ + 'success' => true, + 'message' => 'تم الإلغاء. الرسوم المستحقة ' . number_format($calc['total']) . ' ل.س ستُضاف لحجزك القادم.', + 'data' => ['pending_fee' => $calc['total']], + ]); + } + + // ── Pending debt (AJAX for booking modal) ───────────────────────────────── + + public function pendingDebt(): \Illuminate\Http\JsonResponse + { + $debt = Booking::where('user_id', Auth::id()) + ->where('status', 'cancelled') + ->where('total_fee', '>', 0) + ->whereNull('paid_at') + ->sum('total_fee'); + + return response()->json(['success' => true, 'data' => ['debt' => (float) $debt]]); } } diff --git a/app/Models/Booking.php b/app/Models/Booking.php index dcdb75f..1e0bae5 100644 --- a/app/Models/Booking.php +++ b/app/Models/Booking.php @@ -26,6 +26,7 @@ class Booking extends Model 'payment_proof', 'paid_at', 'pricing_snapshot', + 'notes', ]; protected $casts = [ diff --git a/database/migrations/2026_04_16_133434_add_notes_to_bookings_table.php b/database/migrations/2026_04_16_133434_add_notes_to_bookings_table.php new file mode 100644 index 0000000..d0184ea --- /dev/null +++ b/database/migrations/2026_04_16_133434_add_notes_to_bookings_table.php @@ -0,0 +1,25 @@ +text('notes')->nullable()->after('pricing_snapshot'); + }); + } + + public function down(): void + { + Schema::table('bookings', function (Blueprint $table) { + $table->dropColumn('notes'); + }); + } +}; diff --git a/resources/views/admin/bookings/active.blade.php b/resources/views/admin/bookings/active.blade.php index d82a2d6..5117b35 100644 --- a/resources/views/admin/bookings/active.blade.php +++ b/resources/views/admin/bookings/active.blade.php @@ -338,47 +338,185 @@ -{{-- ── Confirm complete modal ───────────────────────────────────────────────── --}} - @@ -530,6 +545,8 @@ return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } + let userPendingDebt = 0; + function calcTotal() { const start = document.getElementById('bkStart').value; const end = document.getElementById('bkEnd').value; @@ -541,6 +558,16 @@ document.getElementById('bkDuration').textContent = hours + (hours === 1 ? ' ساعة' : ' ساعات'); document.getElementById('bkHourlyRate').textContent = fmtPrice(current.price); document.getElementById('bkTotal').textContent = fmtPrice(total); + + // Show debt row if user has pending fees + const debtRow = document.getElementById('bkDebtRow'); + if (userPendingDebt > 0) { + document.getElementById('bkDebtAmt').textContent = fmtPrice(userPendingDebt); + document.getElementById('bkGrandTotal').textContent = fmtPrice(total + userPendingDebt); + debtRow.style.display = ''; + } else { + debtRow.style.display = 'none'; + } } function resetPrice() { @@ -552,7 +579,7 @@ document.getElementById('bkStart').addEventListener('change', calcTotal); document.getElementById('bkEnd').addEventListener('change', calcTotal); - function openBookingModal(lot) { + async function openBookingModal(lot) { document.getElementById('bookingLotName').textContent = lot.name; document.getElementById('bookingLotMeta').textContent = lot.address + ' · ' + fmtPrice(lot.price) + ' / ساعة'; document.getElementById('bookingUserInfo').textContent = @@ -568,6 +595,16 @@ document.getElementById('bookingAlert').className = 'd-none mb-3'; document.getElementById('bookingAlert').innerHTML = ''; + document.getElementById('bkDebtRow').style.display = 'none'; + + // Fetch pending debt (if logged in) + userPendingDebt = 0; + try { + const dr = await fetch('/user/pending-debt'); + const dd = await dr.json(); + if (dd.success) userPendingDebt = dd.data.debt || 0; + } catch {} + calcTotal(); getBookingModal().show(); } diff --git a/resources/views/layouts/user.blade.php b/resources/views/layouts/user.blade.php index e8d0760..7e1ff47 100644 --- a/resources/views/layouts/user.blade.php +++ b/resources/views/layouts/user.blade.php @@ -58,5 +58,6 @@ @yield('content') + @stack('scripts') diff --git a/resources/views/operator/dashboard.blade.php b/resources/views/operator/dashboard.blade.php index 9e8f086..a5d7d16 100644 --- a/resources/views/operator/dashboard.blade.php +++ b/resources/views/operator/dashboard.blade.php @@ -336,11 +336,15 @@ $gradients = [ onclick="activateRes({{ $res->id }}, this)"> تسجيل دخول - + @@ -590,6 +594,48 @@ $gradients = [ +{{-- ══════════════════════════════════════════════════════════════════════ + CANCEL RESERVATION MODAL +══════════════════════════════════════════════════════════════════════ --}} + + {{-- ══════════════════════════════════════════════════════════════════════ ACTIVATE RESERVATION CONFIRM MODAL ══════════════════════════════════════════════════════════════════════ --}} @@ -783,6 +829,81 @@ document.getElementById('activateConfirmBtn').addEventListener('click', async () } }); +// ── Cancel reservation ──────────────────────────────────────────────── +let cancelBookingId = null; +let cancelName = ''; +let cancelPhone = ''; +let cancelModal = null; + +function getCancelModal() { + if (!cancelModal) cancelModal = new bootstrap.Modal(document.getElementById('cancelResModal')); + return cancelModal; +} + +function openCancelModal(id, name, phone) { + cancelBookingId = id; + cancelName = (name || '').trim().toLowerCase(); + cancelPhone = (phone || '').trim(); + + const input = document.getElementById('cancelVerifyInput'); + const hint = document.getElementById('cancelVerifyHint'); + const btn = document.getElementById('cancelResConfirmBtn'); + input.value = ''; + hint.textContent = ''; + btn.disabled = true; + getCancelModal().show(); + setTimeout(() => input.focus(), 400); +} + +document.getElementById('cancelVerifyInput').addEventListener('input', function () { + const val = this.value.trim(); + const btn = document.getElementById('cancelResConfirmBtn'); + const hint = document.getElementById('cancelVerifyHint'); + const nameOk = cancelName && val.toLowerCase() === cancelName; + const phoneOk = cancelPhone && val === cancelPhone; + const ok = nameOk || phoneOk; + btn.disabled = !ok; + hint.textContent = ok ? '✓ تم التحقق' : (val.length > 0 ? 'لا يطابق الاسم أو الهاتف' : ''); + hint.style.color = ok ? '#10b981' : '#ef4444'; +}); + +document.getElementById('cancelResConfirmBtn').addEventListener('click', async () => { + if (!cancelBookingId) return; + const btn = document.getElementById('cancelResConfirmBtn'); + btn.disabled = true; + btn.innerHTML = 'جاري الإلغاء...'; + + try { + const fd = new FormData(); + fd.append('verification', document.getElementById('cancelVerifyInput').value.trim()); + const res = await fetch(`/operator/${cancelBookingId}/cancel`, { + method: 'POST', headers: { 'X-CSRF-TOKEN': csrf }, body: fd + }); + const data = await res.json(); + if (data.success) { + getCancelModal().hide(); + const card = document.getElementById('card-res-' + cancelBookingId); + if (card) { card.style.transition = 'opacity .4s'; card.style.opacity = '0'; setTimeout(() => card.remove(), 400); } + showToast('تم إلغاء الحجز بنجاح', 'success'); + } else { + showToast(data.message || 'حدث خطأ', 'danger'); + btn.disabled = false; + btn.innerHTML = 'تأكيد الإلغاء'; + } + } catch { + showToast('خطأ في الاتصال', 'danger'); + btn.disabled = false; + btn.innerHTML = 'تأكيد الإلغاء'; + } +}); + +document.getElementById('cancelResModal').addEventListener('hidden.bs.modal', () => { + document.getElementById('cancelVerifyInput').value = ''; + document.getElementById('cancelVerifyHint').textContent = ''; + document.getElementById('cancelResConfirmBtn').disabled = true; + document.getElementById('cancelResConfirmBtn').innerHTML = 'تأكيد الإلغاء'; +}); + // ── Receipt modal ────────────────────────────────────────────────────── let receiptModal = null; let currentBookingId = null; diff --git a/resources/views/user/dashboard.blade.php b/resources/views/user/dashboard.blade.php index 2601223..7187514 100644 --- a/resources/views/user/dashboard.blade.php +++ b/resources/views/user/dashboard.blade.php @@ -16,6 +16,27 @@ +{{-- ── Debt banner ─────────────────────────────────────────────────────────── --}} +@if($pendingDebt > 0) +
+
+ +
+
+
رصيد مستحق غير مدفوع
+
+ لديك رسوم إلغاء متراكمة بقيمة + {{ number_format($pendingDebt) }} ل.س + — ستُضاف تلقائياً إلى حجزك القادم. +
+
+
+ {{ number_format($pendingDebt) }} ل.س +
+
+@endif + {{-- ── Stats row ──────────────────────────────────────────────────────────── --}}
@php @@ -73,36 +94,85 @@ # الموقف - تاريخ البدء - تاريخ الانتهاء + بدء + انتهاء + الرسوم الحالة + إجراء @foreach($bookings as $booking) - + @php + $canCancel = $booking->status === 'active' && $booking->source === 'reservation'; + $hasDebt = $booking->status === 'cancelled' && $booking->total_fee > 0 && is_null($booking->paid_at); + $isFree = $booking->status === 'cancelled' && ($booking->total_fee == 0 || is_null($booking->total_fee)); + $isPaid = $booking->status === 'completed' || ($booking->status === 'cancelled' && !is_null($booking->paid_at) && $booking->total_fee > 0); + @endphp + {{ $booking->id }} + - - {{ $booking->parkingLot?->name ?? '—' }} - + {{ $booking->parkingLot?->name ?? '—' }}
{{ $booking->parkingLot?->address }}
+ {{ $booking->start_time->format('Y/m/d') }}
{{ $booking->start_time->format('H:i') }}
+ {{ $booking->end_time->format('Y/m/d') }}
{{ $booking->end_time->format('H:i') }}
+ + {{-- Fee column --}} + + @if($hasDebt) + {{ number_format($booking->total_fee) }} ل.س +
مستحق
+ @elseif($booking->total_fee > 0 && !is_null($booking->paid_at)) + {{ number_format($booking->total_fee) }} ل.س +
مدفوع
+ @elseif($booking->total_fee > 0) + {{ number_format($booking->total_fee) }} ل.س + @elseif($isFree && $booking->status === 'cancelled') + مجاني + @else + + @endif + + + {{-- Status column --}} @if($booking->status === 'active') - نشط + @if($booking->source === 'reservation') + حجز مسبق + @else + داخل الموقف + @endif @elseif($booking->status === 'completed') مكتمل @else - ملغي + @if($hasDebt) + ملغي — رسوم معلقة + @else + ملغي + @endif + @endif + + + {{-- Action column --}} + + @if($canCancel) + + @else + @endif @@ -113,4 +183,333 @@ @endif
+ +{{-- ══════════════════════════════════════════════════════════════════════════ + CANCEL RESERVATION MODAL +══════════════════════════════════════════════════════════════════════════ --}} + + +{{-- Toast container --}} +
+ +@push('scripts') + +@endpush + @endsection diff --git a/routes/web.php b/routes/web.php index ab6f85c..e4dcb2e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -17,6 +17,9 @@ Route::middleware('auth')->group(function () { // Booking from the public landing page (web session auth) Route::post('/reserve', [ProfileController::class, 'reserve'])->name('reserve'); + Route::get('/reservations/{booking}/cancel-preview', [ProfileController::class, 'cancelPreview'])->name('reservations.cancel-preview'); + Route::post('/reservations/{booking}/cancel', [ProfileController::class, 'cancelBooking'])->name('reservations.cancel'); + Route::get('/user/pending-debt', [ProfileController::class, 'pendingDebt'])->name('user.pending-debt'); }); // Admin Dashboard routes (protected - uncomment auth middleware for production) @@ -37,6 +40,7 @@ Route::prefix('admin')->middleware('admin')->name('admin.')->group(function () { // Active Bookings Route::get('/bookings/active', [\App\Http\Controllers\Admin\BookingController::class, 'activeIndex'])->name('bookings.active'); + Route::get('/bookings/{booking}/checkout-preview', [\App\Http\Controllers\Admin\BookingController::class, 'checkoutPreview'])->name('bookings.checkout-preview'); Route::post('/bookings/{booking}/complete', [\App\Http\Controllers\Admin\BookingController::class, 'completeBooking'])->name('bookings.complete'); }); // }); @@ -49,6 +53,7 @@ Route::prefix('operator')->middleware('operator')->name('operator.')->group(func Route::get('/{booking}/checkout-preview', [\App\Http\Controllers\Operator\OperatorController::class, 'checkoutPreview'])->name('checkoutPreview'); Route::post('/{booking}/payment', [\App\Http\Controllers\Operator\OperatorController::class, 'processPayment'])->name('payment'); Route::post('/{booking}/checkout', [\App\Http\Controllers\Operator\OperatorController::class, 'checkOut'])->name('checkOut'); + Route::post('/{booking}/cancel', [\App\Http\Controllers\Operator\OperatorController::class, 'cancelReservation'])->name('cancel'); }); // });