417 lines
18 KiB
PHP
417 lines
18 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Operator;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Booking;
|
||
use App\Models\ParkingLot;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Carbon\Carbon;
|
||
|
||
class OperatorController extends Controller
|
||
{
|
||
// ── Lot picker + panel ──────────────────────────────────────────────────────
|
||
|
||
public function dashboard(Request $request): \Illuminate\View\View
|
||
{
|
||
$user = Auth::user();
|
||
$assignedLotIds = $user->assignedLots->pluck('id')->toArray();
|
||
|
||
// Operators only see their assigned lots; no assignment = no lots visible
|
||
$query = ParkingLot::active()->withStatus();
|
||
if (!empty($assignedLotIds)) {
|
||
$query->whereIn('id', $assignedLotIds);
|
||
} else {
|
||
$query->whereRaw('1 = 0'); // no lots assigned → show nothing
|
||
}
|
||
|
||
$rawLots = $query->get();
|
||
|
||
$parkingLots = $rawLots->map(fn($lot) => [
|
||
'id' => $lot->id,
|
||
'name' => $lot->name,
|
||
'address' => $lot->address,
|
||
'total' => $lot->total_capacity,
|
||
'avail' => max(0, $lot->total_capacity - ($lot->active_bookings_count + $lot->active_registries_count)),
|
||
'occupied' => $lot->active_bookings_count + $lot->active_registries_count,
|
||
'price' => (float) $lot->price_per_hour,
|
||
'hours' => $lot->working_hours,
|
||
'lat' => (float) $lot->latitude,
|
||
'lng' => (float) $lot->longitude,
|
||
'image' => $lot->image ? Storage::url($lot->image) : null,
|
||
'locked' => false,
|
||
])->values();
|
||
|
||
$selectedLotId = $request->get('lot_id');
|
||
|
||
// Reject attempts to access a lot not in the operator's assigned list
|
||
if ($selectedLotId && !in_array((int) $selectedLotId, $assignedLotIds)) {
|
||
if (!empty($assignedLotIds)) {
|
||
return redirect()->route('operator.dashboard', ['lot_id' => $assignedLotIds[0]]);
|
||
}
|
||
return redirect()->route('operator.dashboard');
|
||
}
|
||
|
||
// Auto-select when only one lot is assigned
|
||
if (!$selectedLotId && count($assignedLotIds) === 1) {
|
||
$selectedLotId = $assignedLotIds[0];
|
||
}
|
||
|
||
$selectedLot = null;
|
||
$activeCars = collect();
|
||
$reservations = collect();
|
||
|
||
if ($selectedLotId) {
|
||
$selectedLot = ParkingLot::active()->findOrFail($selectedLotId);
|
||
|
||
// Walk-in cars currently inside (source=walk_in, status=active)
|
||
$activeCars = Booking::where('parking_lot_id', $selectedLotId)
|
||
->where('source', 'walk_in')
|
||
->where('status', 'active')
|
||
->latest('start_time')
|
||
->get();
|
||
|
||
// Pre-reservations not yet activated (source=reservation, status=active)
|
||
$reservations = Booking::where('parking_lot_id', $selectedLotId)
|
||
->where('source', 'reservation')
|
||
->where('status', 'active')
|
||
->orderBy('start_time')
|
||
->get();
|
||
}
|
||
|
||
return view('operator.dashboard', compact(
|
||
'parkingLots', 'selectedLot', 'activeCars', 'reservations', 'selectedLotId', 'assignedLotIds'
|
||
));
|
||
}
|
||
|
||
// ── Operator stats page ─────────────────────────────────────────────────────
|
||
|
||
public function statsPage(): \Illuminate\View\View
|
||
{
|
||
$user = Auth::user();
|
||
$assignedLotIds = $user->assignedLots->pluck('id')->toArray();
|
||
$lots = ParkingLot::whereIn('id', $assignedLotIds)->orderBy('name')->get();
|
||
|
||
return view('operator.stats', compact('lots', 'assignedLotIds'));
|
||
}
|
||
|
||
public function statsJson(): JsonResponse
|
||
{
|
||
$user = Auth::user();
|
||
$assignedLotIds = $user->assignedLots->pluck('id')->toArray();
|
||
|
||
// Active vehicles right now
|
||
$activeCars = Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('source', 'walk_in')
|
||
->where('status', 'active')
|
||
->count();
|
||
|
||
// Walk-in check-ins started today
|
||
$checkInsToday = Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('source', 'walk_in')
|
||
->whereDate('start_time', today())
|
||
->count();
|
||
|
||
// Revenue collected today (completed + paid today)
|
||
$revenueToday = (float) Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('status', 'completed')
|
||
->whereDate('paid_at', today())
|
||
->sum('total_fee');
|
||
|
||
// Total capacity & occupied across assigned lots
|
||
$lots = ParkingLot::whereIn('id', $assignedLotIds)->get();
|
||
$totalCapacity = $lots->sum('total_capacity');
|
||
$totalOccupied = Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('status', 'active')
|
||
->count();
|
||
$availableSpaces = max(0, $totalCapacity - $totalOccupied);
|
||
|
||
// Pending reservations (not yet activated)
|
||
$pendingReservations = Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('source', 'reservation')
|
||
->where('status', 'active')
|
||
->count();
|
||
|
||
// 7-day daily check-ins and revenue
|
||
$dailyCheckIns = [];
|
||
$dailyRevenue = [];
|
||
$dailyDates = [];
|
||
for ($i = 6; $i >= 0; $i--) {
|
||
$date = now()->subDays($i)->toDateString();
|
||
$dailyDates[] = $date;
|
||
$dailyCheckIns[] = Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('source', 'walk_in')
|
||
->whereDate('start_time', $date)
|
||
->count();
|
||
$dailyRevenue[] = (float) Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('status', 'completed')
|
||
->whereDate('paid_at', $date)
|
||
->sum('total_fee');
|
||
}
|
||
|
||
// Per-lot breakdown
|
||
$lotStats = $lots->map(function ($lot) {
|
||
$active = Booking::where('parking_lot_id', $lot->id)->where('status', 'active')->count();
|
||
$todayIns = Booking::where('parking_lot_id', $lot->id)
|
||
->where('source', 'walk_in')
|
||
->whereDate('start_time', today())
|
||
->count();
|
||
$todayRev = (float) Booking::where('parking_lot_id', $lot->id)
|
||
->where('status', 'completed')
|
||
->whereDate('paid_at', today())
|
||
->sum('total_fee');
|
||
return [
|
||
'id' => $lot->id,
|
||
'name' => $lot->name,
|
||
'address' => $lot->address,
|
||
'total' => $lot->total_capacity,
|
||
'active' => $active,
|
||
'available' => max(0, $lot->total_capacity - $active),
|
||
'pct' => $lot->total_capacity > 0 ? round($active / $lot->total_capacity * 100) : 0,
|
||
'today_ins' => $todayIns,
|
||
'today_rev' => $todayRev,
|
||
];
|
||
})->values();
|
||
|
||
// Last 8 completed bookings
|
||
$recentCompletions = Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('status', 'completed')
|
||
->with('parkingLot:id,name')
|
||
->latest('paid_at')
|
||
->limit(8)
|
||
->get(['id', 'parking_lot_id', 'vehicle_plate', 'customer_name', 'start_time', 'paid_at', 'total_fee', 'payment_method']);
|
||
|
||
// ── Work progress ──────────────────────────────────────────────────────
|
||
// Checkouts completed today
|
||
$checkoutsToday = Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('status', 'completed')
|
||
->whereDate('paid_at', today())
|
||
->count();
|
||
|
||
// Cancellations today
|
||
$cancelledToday = Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('status', 'cancelled')
|
||
->whereDate('updated_at', today())
|
||
->count();
|
||
|
||
// Yesterday check-ins (comparison)
|
||
$checkInsYesterday = Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('source', 'walk_in')
|
||
->whereDate('start_time', today()->subDay())
|
||
->count();
|
||
|
||
// Average stay duration in minutes (completed bookings today, start→paid_at)
|
||
$completedToday = Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('status', 'completed')
|
||
->whereDate('paid_at', today())
|
||
->get(['start_time', 'paid_at']);
|
||
|
||
$avgDurationMin = $completedToday->isNotEmpty()
|
||
? round($completedToday->avg(fn($b) => $b->start_time->diffInMinutes($b->paid_at)))
|
||
: 0;
|
||
|
||
// Completion rate today: completed / (completed + cancelled)
|
||
$totalClosed = $checkoutsToday + $cancelledToday;
|
||
$completionRate = $totalClosed > 0 ? round($checkoutsToday / $totalClosed * 100) : 100;
|
||
|
||
// Hourly activity today (check-ins per hour 0–23)
|
||
$hourlyActivity = array_fill(0, 24, 0);
|
||
Booking::whereIn('parking_lot_id', $assignedLotIds)
|
||
->where('source', 'walk_in')
|
||
->whereDate('start_time', today())
|
||
->get(['start_time'])
|
||
->each(function ($b) use (&$hourlyActivity) {
|
||
$hourlyActivity[(int) $b->start_time->format('H')]++;
|
||
});
|
||
|
||
$peakHour = (int) array_search(max($hourlyActivity), $hourlyActivity);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'data' => [
|
||
'active_cars' => $activeCars,
|
||
'checkins_today' => $checkInsToday,
|
||
'revenue_today' => $revenueToday,
|
||
'available_spaces' => $availableSpaces,
|
||
'pending_reservations' => $pendingReservations,
|
||
'daily_checkins' => $dailyCheckIns,
|
||
'daily_revenue' => $dailyRevenue,
|
||
'daily_dates' => $dailyDates,
|
||
'lot_stats' => $lotStats,
|
||
'recent_completions' => $recentCompletions,
|
||
// work progress
|
||
'checkouts_today' => $checkoutsToday,
|
||
'cancelled_today' => $cancelledToday,
|
||
'checkins_yesterday' => $checkInsYesterday,
|
||
'avg_duration_min' => $avgDurationMin,
|
||
'completion_rate' => $completionRate,
|
||
'hourly_activity' => array_values($hourlyActivity),
|
||
'peak_hour' => $peakHour,
|
||
],
|
||
]);
|
||
}
|
||
|
||
// ── Walk-in check-in ────────────────────────────────────────────────────────
|
||
|
||
public function checkIn(Request $request): JsonResponse
|
||
{
|
||
$data = $request->validate([
|
||
'parking_lot_id' => 'required|exists:parking_lots,id',
|
||
'vehicle_plate' => 'required|string|max:50',
|
||
'customer_name' => 'nullable|string|max:255',
|
||
'phone' => 'nullable|string|max:20',
|
||
'duration_hours' => 'required|numeric|min:0.25|max:72',
|
||
]);
|
||
|
||
$lot = ParkingLot::findOrFail($data['parking_lot_id']);
|
||
|
||
$activeCount = $lot->bookings()->where('status', 'active')->count();
|
||
if ($activeCount >= $lot->total_capacity) {
|
||
return response()->json(['success' => false, 'message' => 'الموقف ممتلئ حالياً'], 422);
|
||
}
|
||
|
||
$booking = Booking::create([
|
||
'parking_lot_id' => $lot->id,
|
||
'vehicle_plate' => $data['vehicle_plate'],
|
||
'customer_name' => $data['customer_name'] ?? null,
|
||
'phone' => $data['phone'] ?? null,
|
||
'source' => 'walk_in',
|
||
'start_time' => now(),
|
||
'end_time' => now()->addHours((float) $data['duration_hours']),
|
||
'status' => 'active',
|
||
'pricing_snapshot' => $lot->pricingSnapshot(),
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'message' => 'تم تسجيل دخول السيارة بنجاح',
|
||
'data' => ['id' => $booking->id, 'plate' => $booking->vehicle_plate],
|
||
]);
|
||
}
|
||
|
||
// ── Activate a reservation (open parking for reserved car) ─────────────────
|
||
|
||
public function activateReservation(Booking $booking): JsonResponse
|
||
{
|
||
if ($booking->source !== 'reservation' || $booking->status !== 'active') {
|
||
return response()->json(['success' => false, 'message' => 'الحجز غير صالح للتفعيل'], 400);
|
||
}
|
||
|
||
$booking->update([
|
||
'source' => 'walk_in', // now treated as an active walk-in
|
||
'start_time' => now(),
|
||
'end_time' => now()->addHours(
|
||
max(1, now()->diffInHours($booking->end_time, false))
|
||
),
|
||
]);
|
||
|
||
return response()->json(['success' => true, 'message' => 'تم تفعيل الحجز وفتح بوابة الدخول']);
|
||
}
|
||
|
||
// ── Checkout: calculate fee and return receipt data ─────────────────────────
|
||
|
||
public function checkoutPreview(Booking $booking): JsonResponse
|
||
{
|
||
if ($booking->status !== 'active') {
|
||
return response()->json(['success' => false, 'message' => 'الحجز غير نشط'], 400);
|
||
}
|
||
|
||
$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,
|
||
'data' => [
|
||
'booking_id' => $booking->id,
|
||
'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_min' => $duration,
|
||
'duration_label'=> floor($duration / 60) . 'س ' . ($duration % 60) . 'د',
|
||
'fee_details' => $calc['details'],
|
||
'total_fee' => $calc['total'],
|
||
],
|
||
]);
|
||
}
|
||
|
||
// ── Process payment and complete booking ─────────────────────────────────────
|
||
|
||
public function processPayment(Request $request, Booking $booking): JsonResponse
|
||
{
|
||
$data = $request->validate([
|
||
'payment_method' => 'required|in:cash,upload',
|
||
'payment_proof' => 'nullable|file|mimes:jpg,jpeg,png,pdf|max:4096',
|
||
]);
|
||
|
||
if ($booking->status !== 'active') {
|
||
return response()->json(['success' => false, 'message' => 'الحجز غير نشط'], 400);
|
||
}
|
||
|
||
$lot = $booking->parkingLot;
|
||
$start = $booking->start_time;
|
||
$end = now();
|
||
$calc = $lot->calculateFee($start, $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' => $data['payment_method'],
|
||
'payment_proof' => $proofPath,
|
||
'paid_at' => now(),
|
||
]);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'message' => 'تم تسجيل الخروج وإتمام الدفع بنجاح',
|
||
'data' => [
|
||
'total_fee' => $calc['total'],
|
||
'payment_method' => $data['payment_method'],
|
||
],
|
||
]);
|
||
}
|
||
|
||
// ── 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
|
||
{
|
||
return $this->processPayment($request->merge(['payment_method' => 'cash']), $booking);
|
||
}
|
||
}
|