latest
This commit is contained in:
1 parent
b03c1d4619
commit
b07423aef3
42 files changed
+1431
-394
No files matched your search
@@ -14,7 +14,7 @@ class AdminOperatorController extends Controller
|
||||
public function index()
|
||||
{
|
||||
$operators = User::where('role', 'operator')
|
||||
->with('assignedLot')
|
||||
->with('assignedLots')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
@@ -26,24 +26,26 @@ class AdminOperatorController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'password' => ['required', Password::min(8)],
|
||||
'parking_lot_id' => 'nullable|exists:parking_lots,id',
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'password' => ['required', Password::min(8)],
|
||||
'lot_ids' => 'nullable|array',
|
||||
'lot_ids.*'=> 'exists:parking_lots,id',
|
||||
], [
|
||||
'email.unique' => 'هذا البريد الإلكتروني مستخدم بالفعل.',
|
||||
]);
|
||||
|
||||
User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'password' => Hash::make($data['password']),
|
||||
'role' => 'operator',
|
||||
'parking_lot_id' => $data['parking_lot_id'] ?? null,
|
||||
$operator = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'password' => Hash::make($data['password']),
|
||||
'role' => 'operator',
|
||||
]);
|
||||
|
||||
$operator->assignedLots()->sync($data['lot_ids'] ?? []);
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'تم إنشاء حساب المشغّل بنجاح.']);
|
||||
}
|
||||
|
||||
@@ -54,20 +56,20 @@ class AdminOperatorController extends Controller
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email,' . $operator->id,
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'password' => ['nullable', Password::min(8)],
|
||||
'parking_lot_id' => 'nullable|exists:parking_lots,id',
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email,' . $operator->id,
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'password' => ['nullable', Password::min(8)],
|
||||
'lot_ids' => 'nullable|array',
|
||||
'lot_ids.*'=> 'exists:parking_lots,id',
|
||||
], [
|
||||
'email.unique' => 'هذا البريد الإلكتروني مستخدم بالفعل.',
|
||||
]);
|
||||
|
||||
$updates = [
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'parking_lot_id' => $data['parking_lot_id'] ?? null,
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'phone' => $data['phone'] ?? null,
|
||||
];
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
@@ -75,6 +77,7 @@ class AdminOperatorController extends Controller
|
||||
}
|
||||
|
||||
$operator->update($updates);
|
||||
$operator->assignedLots()->sync($data['lot_ids'] ?? []);
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'تم تحديث بيانات المشغّل.']);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,18 @@ class OperatorController extends Controller
|
||||
|
||||
public function dashboard(Request $request): \Illuminate\View\View
|
||||
{
|
||||
$user = Auth::user();
|
||||
$assignedLotId = $user->parking_lot_id; // null = no restriction (e.g. admin)
|
||||
$user = Auth::user();
|
||||
$assignedLotIds = $user->assignedLots->pluck('id')->toArray();
|
||||
|
||||
$rawLots = ParkingLot::active()->withStatus()->get();
|
||||
// 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,
|
||||
@@ -34,20 +42,22 @@ class OperatorController extends Controller
|
||||
'lat' => (float) $lot->latitude,
|
||||
'lng' => (float) $lot->longitude,
|
||||
'image' => $lot->image ? Storage::url($lot->image) : null,
|
||||
'locked' => $assignedLotId !== null && $lot->id !== $assignedLotId,
|
||||
'locked' => false,
|
||||
])->values();
|
||||
|
||||
// If operator has an assigned lot, force that lot
|
||||
$selectedLotId = $request->get('lot_id');
|
||||
if ($assignedLotId !== null) {
|
||||
// Reject any attempt to view a different lot
|
||||
if ($selectedLotId && (int) $selectedLotId !== $assignedLotId) {
|
||||
return redirect()->route('operator.dashboard', ['lot_id' => $assignedLotId]);
|
||||
}
|
||||
// Auto-select assigned lot if nothing selected
|
||||
if (!$selectedLotId) {
|
||||
$selectedLotId = $assignedLotId;
|
||||
|
||||
// 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;
|
||||
@@ -73,10 +83,177 @@ class OperatorController extends Controller
|
||||
}
|
||||
|
||||
return view('operator.dashboard', compact(
|
||||
'parkingLots', 'selectedLot', 'activeCars', 'reservations', 'selectedLotId', 'assignedLotId'
|
||||
'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
|
||||
|
||||
Reference in new issue
Block a user