backup: before auth-required booking and phone number feature

This commit is contained in:
Ghassan Yusuf 2026-04-16 09:43:55 +03:00
parent 83d9a67188
commit 900eb24b37
10 changed files with 618 additions and 253 deletions

View File

@ -15,7 +15,11 @@
"Bash(git reset:*)", "Bash(git reset:*)",
"Bash(php -l app/Http/Controllers/Admin/ParkingLotController.php)", "Bash(php -l app/Http/Controllers/Admin/ParkingLotController.php)",
"Bash(php -l app/Http/Requests/StoreParkingLotRequest.php)", "Bash(php -l app/Http/Requests/StoreParkingLotRequest.php)",
"Bash(php -l app/Http/Controllers/Admin/BookingController.php)" "Bash(php -l app/Http/Controllers/Admin/BookingController.php)",
"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:*)"
] ]
} }
} }

View File

@ -52,6 +52,10 @@ class ParkingLotController extends Controller
{ {
$data = $request->validated(); $data = $request->validated();
// Never let validated() overwrite the stored image path with null.
// Only update the image column when a new file is actually uploaded.
unset($data['image']);
if ($request->hasFile('image')) { if ($request->hasFile('image')) {
if ($parkingLot->image) { if ($parkingLot->image) {
Storage::disk('public')->delete($parkingLot->image); Storage::disk('public')->delete($parkingLot->image);
@ -91,6 +95,38 @@ class ParkingLotController extends Controller
]); ]);
} }
public function updatePricing(Request $request, ParkingLot $parkingLot): JsonResponse
{
$data = $request->validate([
'price_per_hour' => 'required|numeric|min:0|max:100000',
'pricing_rules' => 'nullable|array',
'pricing_rules.*'=> 'nullable|numeric|min:0|max:100000',
]);
// Build clean rules array: only include days that differ from base price.
// Keys 1-7 (Mon-Sun). Null or empty string means "use base price".
$rules = [];
foreach ($data['pricing_rules'] ?? [] as $day => $rate) {
if ($rate !== null && $rate !== '' && (float) $rate !== (float) $data['price_per_hour']) {
$rules[(int) $day] = (float) $rate;
}
}
$parkingLot->update([
'price_per_hour' => $data['price_per_hour'],
'pricing_rules' => empty($rules) ? null : $rules,
]);
return response()->json([
'success' => true,
'message' => 'تم حفظ أسعار الموقف بنجاح',
'data' => [
'price_per_hour' => (float) $parkingLot->price_per_hour,
'pricing_rules' => $parkingLot->pricing_rules,
],
]);
}
public function toggleStatus(ParkingLot $parkingLot): JsonResponse public function toggleStatus(ParkingLot $parkingLot): JsonResponse
{ {
$parkingLot->update(['is_active' => !$parkingLot->is_active]); $parkingLot->update(['is_active' => !$parkingLot->is_active]);

View File

@ -18,6 +18,9 @@ class BookingController extends Controller
{ {
$validated = $request->validated(); $validated = $request->validated();
$lot = \App\Models\ParkingLot::find($validated['parking_lot_id']);
$validated['pricing_snapshot'] = $lot?->pricingSnapshot();
$booking = \App\Models\Booking::create($validated); $booking = \App\Models\Booking::create($validated);
// Note: In production, create CarRegistry here for the booking car // Note: In production, create CarRegistry here for the booking car

View File

@ -80,14 +80,15 @@ class OperatorController extends Controller
} }
$booking = Booking::create([ $booking = Booking::create([
'parking_lot_id' => $lot->id, 'parking_lot_id' => $lot->id,
'vehicle_plate' => $data['vehicle_plate'], 'vehicle_plate' => $data['vehicle_plate'],
'customer_name' => $data['customer_name'] ?? null, 'customer_name' => $data['customer_name'] ?? null,
'phone' => $data['phone'] ?? null, 'phone' => $data['phone'] ?? null,
'source' => 'walk_in', 'source' => 'walk_in',
'start_time' => now(), 'start_time' => now(),
'end_time' => now()->addHours($data['duration_hours']), 'end_time' => now()->addHours($data['duration_hours']),
'status' => 'active', 'status' => 'active',
'pricing_snapshot' => $lot->pricingSnapshot(),
]); ]);
return response()->json([ return response()->json([
@ -128,7 +129,7 @@ class OperatorController extends Controller
$start = $booking->start_time; $start = $booking->start_time;
$end = now(); $end = now();
$duration = $start->diffInMinutes($end); $duration = $start->diffInMinutes($end);
$calc = $lot->calculateFee($start, $end); $calc = $lot->calculateFee($start, $end, $booking->pricing_snapshot);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@ -163,7 +164,7 @@ class OperatorController extends Controller
$lot = $booking->parkingLot; $lot = $booking->parkingLot;
$start = $booking->start_time; $start = $booking->start_time;
$end = now(); $end = now();
$calc = $lot->calculateFee($start, $end); $calc = $lot->calculateFee($start, $end, $booking->pricing_snapshot);
$proofPath = null; $proofPath = null;
if ($request->hasFile('payment_proof')) { if ($request->hasFile('payment_proof')) {

View File

@ -25,13 +25,15 @@ class Booking extends Model
'payment_method', 'payment_method',
'payment_proof', 'payment_proof',
'paid_at', 'paid_at',
'pricing_snapshot',
]; ];
protected $casts = [ protected $casts = [
'start_time' => 'datetime', 'start_time' => 'datetime',
'end_time' => 'datetime', 'end_time' => 'datetime',
'paid_at' => 'datetime', 'paid_at' => 'datetime',
'total_fee' => 'decimal:2', 'total_fee' => 'decimal:2',
'pricing_snapshot' => 'array',
]; ];
public function parkingLot(): BelongsTo public function parkingLot(): BelongsTo

View File

@ -81,15 +81,33 @@ class ParkingLot extends Model
return $activeBookings + $activeRegistries; return $activeBookings + $activeRegistries;
} }
/**
* Build the pricing snapshot array to store on a booking at creation time.
* Format: ['base' => float, 'rules' => [1 => float, ..., 7 => float]]
*/
public function pricingSnapshot(): array
{
return [
'base' => (float) $this->price_per_hour,
'rules' => $this->pricing_rules ?? [],
];
}
/** /**
* Calculate fee and per-day breakdown between two timestamps. * Calculate fee and per-day breakdown between two timestamps.
* Uses pricing_rules (ISO weekday keys 17) if set, otherwise price_per_hour. *
* Pass a $snapshot (from booking->pricing_snapshot) to use the rates that were
* in effect when the booking was created, guaranteeing price changes never
* retroactively affect historical or in-progress bookings.
* *
* Returns ['total' => float, 'details' => [['day','date','hours','rate','subtotal'], ...]] * Returns ['total' => float, 'details' => [['day','date','hours','rate','subtotal'], ...]]
*/ */
public function calculateFee(Carbon $start, Carbon $end): array public function calculateFee(Carbon $start, Carbon $end, ?array $snapshot = null): array
{ {
$rules = $this->pricing_rules ?? []; $base = $snapshot ? (float) ($snapshot['base'] ?? $this->price_per_hour)
: (float) $this->price_per_hour;
$rules = $snapshot ? ($snapshot['rules'] ?? [])
: ($this->pricing_rules ?? []);
$details = []; $details = [];
$total = 0.0; $total = 0.0;
$cursor = $start->copy()->seconds(0); $cursor = $start->copy()->seconds(0);
@ -99,7 +117,7 @@ class ParkingLot extends Model
$segEnd = ($dayEnd < $end) ? $dayEnd : $end; $segEnd = ($dayEnd < $end) ? $dayEnd : $end;
$dow = (int) $cursor->format('N'); // 1=Mon … 7=Sun $dow = (int) $cursor->format('N'); // 1=Mon … 7=Sun
$rate = isset($rules[$dow]) ? (float) $rules[$dow] : (float) $this->price_per_hour; $rate = isset($rules[$dow]) ? (float) $rules[$dow] : $base;
$hours = round($cursor->diffInMinutes($segEnd) / 60, 4); $hours = round($cursor->diffInMinutes($segEnd) / 60, 4);
$subtotal = $hours * $rate; $subtotal = $hours * $rate;

View File

@ -0,0 +1,31 @@
<?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) {
// Stores {base: float, rules: {1:float, ..., 7:float}} at booking creation.
// Checkout fee calculation uses this snapshot so price changes never affect
// in-progress or historical bookings.
$table->json('pricing_snapshot')->nullable()->after('paid_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn('pricing_snapshot');
});
}
};

View File

@ -1,4 +1,5 @@
import 'bootstrap'; import * as bootstrap from 'bootstrap';
window.bootstrap = bootstrap;
import axios from 'axios'; import axios from 'axios';
window.axios = axios; window.axios = axios;

View File

@ -5,11 +5,91 @@
@section('styles') @section('styles')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style> <style>
#lotsMap { height: 380px; border-radius: .5rem; z-index: 0; } #modalMap { height: 280px; border-radius: .5rem; z-index: 0; }
#modalMap { height: 280px; border-radius: .5rem; z-index: 0; } .map-hint { font-size: .78rem; color: #64748b; margin-top: .35rem; }
.map-hint { font-size: .78rem; color: #64748b; margin-top: .35rem; }
/* keep Leaflet tiles crisp inside RTL layout */
.leaflet-container { direction: ltr; } .leaflet-container { direction: ltr; }
/* lot portrait cards */
.lot-admin-card {
background: #fff;
border-radius: .75rem;
border: 1px solid #e2e8f0;
overflow: hidden;
display: flex;
flex-direction: column;
transition: box-shadow .2s, transform .2s;
}
.lot-admin-card:hover {
box-shadow: 0 8px 24px rgba(99,102,241,.12);
transform: translateY(-2px);
}
.lot-card-img-wrap {
position: relative;
height: 160px;
flex-shrink: 0;
overflow: hidden;
}
.lot-card-img-wrap img {
width: 100%; height: 100%; object-fit: cover;
}
.lot-card-placeholder {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
.lot-card-placeholder i { font-size: 2.5rem; color: rgba(255,255,255,.6); }
.lot-status-dot {
position: absolute; top: .6rem; left: .6rem;
width: 10px; height: 10px; border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0,0,0,.3);
}
.lot-active-badge {
position: absolute; top: .55rem; right: .55rem;
background: rgba(0,0,0,.45); color: #fff;
font-size: .68rem; font-weight: 700;
padding: .2rem .5rem; border-radius: 2rem;
backdrop-filter: blur(4px);
}
.lot-card-body {
padding: .9rem 1rem .5rem;
flex: 1;
}
.lot-card-body h6 {
font-size: .92rem; font-weight: 700; color: #0f172a;
margin-bottom: .2rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.lot-card-address {
font-size: .75rem; color: #64748b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: .65rem;
}
.lot-card-stats {
display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .5rem;
}
.lot-card-stat {
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: .375rem; padding: .2rem .5rem;
font-size: .72rem; color: #475569; white-space: nowrap;
}
.lot-card-stat strong { color: #0f172a; }
.lot-card-footer {
padding: .6rem 1rem;
border-top: 1px solid #f1f5f9;
display: flex; justify-content: flex-end; gap: .35rem;
}
.lot-action-btn {
width: 30px; height: 30px; border: none; border-radius: .375rem;
display: flex; align-items: center; justify-content: center;
font-size: .8rem; cursor: pointer; transition: opacity .15s;
}
.lot-action-btn:hover { opacity: .8; }
/* modal scrollable */
#lotModal .modal-content { max-height: 90vh; display: flex; flex-direction: column; overflow: hidden; }
#lotModal .modal-content > form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; }
#lotModal .modal-body { overflow-y: auto; flex: 1 1 auto; min-height: 0; }
#lotModal .modal-footer { flex-shrink: 0; }
</style> </style>
@endsection @endsection
@ -31,174 +111,168 @@
</button> </button>
</div> </div>
{{-- ── Overview Map ─────────────────────────────────────────────────────────── --}} {{-- ── Search bar ───────────────────────────────────────────────────────────── --}}
<div class="card mb-4"> <div class="d-flex align-items-center justify-content-between gap-3 mb-4 flex-wrap">
<div class="card-header"> <span class="text-sm fw-600" style="color:#64748b;">
<span class="fw-700 text-sm"> @if(request('search'))
<i class="bi bi-map me-1" style="color:#6366f1;"></i> نتائج البحث عن «{{ request('search') }}»
خريطة المواقف @else
</span> عرض جميع المواقف
</div> @endif
<div class="p-2"> </span>
<div id="lotsMap"></div> <div class="input-group" style="max-width:280px;">
<input type="text" id="searchInput"
class="form-control form-control-sm"
style="border-color:#e2e8f0;border-inline-end:none;"
placeholder="ابحث باسم الموقف أو العنوان..." value="{{ request('search') }}">
<button class="btn btn-sm" style="background:#6366f1;color:#fff;border:none;" onclick="doSearch()">
<i class="bi bi-search"></i>
</button>
@if(request('search'))
<a href="{{ route('admin.parking-lots.index') }}"
class="btn btn-sm" style="background:#f1f5f9;color:#64748b;border:none;">
<i class="bi bi-x-lg"></i>
</a>
@endif
</div> </div>
</div> </div>
{{-- ── Table Card ───────────────────────────────────────────────────────────── --}} {{-- ── Cards grid ───────────────────────────────────────────────────────────── --}}
<div class="card"> @php
$gradients = [
'linear-gradient(135deg,#667eea,#764ba2)',
'linear-gradient(135deg,#f093fb,#f5576c)',
'linear-gradient(135deg,#4facfe,#00f2fe)',
'linear-gradient(135deg,#43e97b,#38f9d7)',
'linear-gradient(135deg,#fa709a,#fee140)',
'linear-gradient(135deg,#a18cd1,#fbc2eb)',
];
@endphp
@if($parkingLots->isEmpty())
<div class="text-center py-5">
<i class="bi bi-buildings d-block mb-3" style="font-size:3rem;color:#cbd5e1;"></i>
<p class="fw-600 mb-1" style="color:#475569;">لا توجد مواقف بعد</p>
<p class="text-sm mb-3" style="color:#94a3b8;">ابدأ بإضافة أول موقف سيارات</p>
<button class="btn fw-600"
style="background:#6366f1;color:#fff;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;"
data-bs-toggle="modal" data-bs-target="#lotModal" id="addLotBtnEmpty">
<i class="bi bi-plus-lg me-1"></i>إضافة موقف
</button>
</div>
@else
<div class="row g-3">
@foreach($parkingLots as $lot)
@php $grad = $gradients[$lot->id % count($gradients)]; @endphp
<div class="col-sm-6 col-md-4 col-xl-3">
<div class="lot-admin-card">
{{-- Image / Placeholder --}}
<div class="lot-card-img-wrap">
@if($lot->image)
<img src="{{ Storage::url($lot->image) }}" alt="{{ $lot->name }}">
@else
<div class="lot-card-placeholder" style="background: {{ $grad }};">
<i class="bi bi-buildings"></i>
</div>
@endif
{{-- Status dot --}}
<span class="lot-status-dot"
style="background:{{ $lot->is_active ? '#10b981' : '#ef4444' }};"
title="{{ $lot->is_active ? 'نشط' : 'معطل' }}">
</span>
{{-- Active bookings badge --}}
@if(($lot->active_bookings_count ?? 0) > 0)
<span class="lot-active-badge">
<i class="bi bi-car-front-fill me-1"></i>{{ $lot->active_bookings_count }} نشط
</span>
@endif
</div>
{{-- Card body --}}
<div class="lot-card-body">
<h6 title="{{ $lot->name }}">{{ $lot->name }}</h6>
<p class="lot-card-address" title="{{ $lot->address }}">
<i class="bi bi-geo-alt me-1" style="color:#94a3b8;"></i>{{ $lot->address }}
</p>
<div class="lot-card-stats">
<span class="lot-card-stat">
<i class="bi bi-car-front" style="color:#6366f1;"></i>
<strong>{{ $lot->total_capacity }}</strong> مركبة
</span>
<span class="lot-card-stat">
<i class="bi bi-clock" style="color:#10b981;"></i>
{{ $lot->working_hours }}
</span>
<span class="lot-card-stat" style="{{ !empty($lot->pricing_rules) ? 'border-color:#fbbf24;color:#92400e;' : '' }}">
<i class="bi bi-{{ !empty($lot->pricing_rules) ? 'tags' : 'tag' }}" style="color:{{ !empty($lot->pricing_rules) ? '#f59e0b' : '#10b981' }};"></i>
<strong>{{ number_format($lot->price_per_hour, 0) }}</strong> ر.س/س
@if(!empty($lot->pricing_rules))
<span style="font-size:.6rem;color:#f59e0b;"> (مخصص)</span>
@endif
</span>
</div>
</div>
{{-- Actions --}}
<div class="lot-card-footer">
<button class="lot-action-btn"
style="background:#f1f5f9;color:#475569;"
data-bs-toggle="modal" data-bs-target="#lotModal"
onclick="editLot({{ $lot->id }})" title="تعديل">
<i class="bi bi-pencil"></i>
</button>
<button class="lot-action-btn"
style="background:rgba(99,102,241,.1);color:#6366f1;"
onclick="openPricingModal({{ $lot->id }}, '{{ addslashes($lot->name) }}')"
title="التسعير الأسبوعي">
<i class="bi bi-tags"></i>
</button>
<button class="lot-action-btn"
style="background:{{ $lot->is_active ? 'rgba(239,68,68,.1)' : 'rgba(16,185,129,.1)' }};color:{{ $lot->is_active ? '#dc2626' : '#059669' }};"
onclick="toggleStatus({{ $lot->id }})"
title="{{ $lot->is_active ? 'تعطيل' : 'تفعيل' }}">
<i class="bi {{ $lot->is_active ? 'bi-slash-circle' : 'bi-check-circle' }}"></i>
</button>
<button class="lot-action-btn"
style="background:rgba(239,68,68,.08);color:#dc2626;"
onclick="deleteLot({{ $lot->id }}, '{{ addslashes($lot->name) }}')"
title="حذف">
<i class="bi bi-trash3"></i>
</button>
</div>
{{-- Toolbar --}}
<div class="card-header d-flex align-items-center justify-content-between gap-3 flex-wrap">
<span class="fw-700 text-sm">
<i class="bi bi-list-ul me-1" style="color:#6366f1;"></i>
قائمة المواقف
</span>
<div class="input-group" style="max-width:260px;">
<input type="text" id="searchInput"
class="form-control form-control-sm"
style="border-color:#e2e8f0;border-inline-end:none;"
placeholder="بحث..." value="{{ request('search') }}">
<button class="btn btn-sm" style="background:#6366f1;color:#fff;border:none;" onclick="doSearch()">
<i class="bi bi-search"></i>
</button>
</div> </div>
</div> </div>
@endforeach
<div class="table-responsive">
<table class="app-table w-100">
<thead>
<tr>
<th>الموقف</th>
<th>العنوان</th>
<th class="text-center">السعة</th>
<th class="text-center">السعر / ساعة</th>
<th class="text-center">ساعات العمل</th>
<th class="text-center">الحالة</th>
<th class="text-center">نشط حالياً</th>
<th class="text-center">إجراءات</th>
</tr>
</thead>
<tbody>
@forelse($parkingLots as $lot)
<tr>
<td>
<div class="d-flex align-items-center gap-2">
@if($lot->image)
<img src="{{ Storage::url($lot->image) }}" alt=""
style="width:36px;height:36px;object-fit:cover;border-radius:.375rem;flex-shrink:0;">
@else
<div style="width:36px;height:36px;background:#e2e8f0;border-radius:.375rem;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="bi bi-buildings" style="color:#94a3b8;font-size:.85rem;"></i>
</div>
@endif
<div>
<div class="fw-600" style="color:#0f172a;">{{ $lot->name }}</div>
<div class="text-xs" style="color:#94a3b8;direction:ltr;text-align:right;">
{{ number_format($lot->latitude, 5) }}, {{ number_format($lot->longitude, 5) }}
</div>
</div>
</div>
</td>
<td>
<span class="text-sm" style="color:#475569;" title="{{ $lot->address }}">
{{ Str::limit($lot->address, 40) }}
</span>
</td>
<td class="text-center">
<span class="badge badge-soft-info fw-600">{{ $lot->total_capacity }}</span>
</td>
<td class="text-center">
<span class="badge badge-soft-success fw-600">
{{ number_format($lot->price_per_hour, 0) }} ر.س
</span>
</td>
<td class="text-center">
<span class="badge badge-soft-secondary text-xs">{{ $lot->working_hours }}</span>
</td>
<td class="text-center">
@if($lot->is_active)
<span class="badge badge-soft-success">
<i class="bi bi-circle-fill me-1" style="font-size:.45rem;vertical-align:middle;"></i>نشط
</span>
@else
<span class="badge badge-soft-danger">
<i class="bi bi-circle-fill me-1" style="font-size:.45rem;vertical-align:middle;"></i>معطل
</span>
@endif
</td>
<td class="text-center">
<span class="badge badge-soft-warning fw-600">{{ $lot->active_bookings_count ?? 0 }}</span>
</td>
<td class="text-center">
<div class="d-inline-flex gap-1">
<button class="btn btn-sm"
style="background:#f1f5f9;color:#475569;border:none;border-radius:.375rem;width:30px;height:30px;padding:0;"
data-bs-toggle="modal" data-bs-target="#lotModal"
onclick="editLot({{ $lot->id }})" title="تعديل">
<i class="bi bi-pencil" style="font-size:.8rem;"></i>
</button>
<button class="btn btn-sm"
style="background:{{ $lot->is_active ? 'rgba(239,68,68,.1)' : 'rgba(16,185,129,.1)' }};color:{{ $lot->is_active ? '#dc2626' : '#059669' }};border:none;border-radius:.375rem;width:30px;height:30px;padding:0;"
onclick="toggleStatus({{ $lot->id }})"
title="{{ $lot->is_active ? 'تعطيل' : 'تفعيل' }}">
<i class="bi {{ $lot->is_active ? 'bi-slash-circle' : 'bi-check-circle' }}" style="font-size:.8rem;"></i>
</button>
<button class="btn btn-sm"
style="background:rgba(239,68,68,.08);color:#dc2626;border:none;border-radius:.375rem;width:30px;height:30px;padding:0;"
onclick="deleteLot({{ $lot->id }}, '{{ addslashes($lot->name) }}')"
title="حذف">
<i class="bi bi-trash3" style="font-size:.8rem;"></i>
</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="text-center py-5">
<i class="bi bi-buildings d-block mb-3" style="font-size:2.5rem;color:#cbd5e1;"></i>
<p class="fw-600 mb-1" style="color:#475569;">لا توجد مواقف بعد</p>
<p class="text-sm mb-3" style="color:#94a3b8;">ابدأ بإضافة أول موقف سيارات</p>
<button class="btn btn-sm fw-600"
style="background:#6366f1;color:#fff;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;"
data-bs-toggle="modal" data-bs-target="#lotModal">
<i class="bi bi-plus-lg me-1"></i>إضافة موقف
</button>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($parkingLots->hasPages())
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<span class="text-xs" style="color:#64748b;">
عرض {{ $parkingLots->firstItem() ?? 0 }}{{ $parkingLots->lastItem() ?? 0 }}
من {{ $parkingLots->total() }}
</span>
{{ $parkingLots->appends(request()->query())->links('pagination::bootstrap-5') }}
</div>
@endif
</div> </div>
{{-- Pagination --}}
@if($parkingLots->hasPages())
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mt-4">
<span class="text-xs" style="color:#64748b;">
عرض {{ $parkingLots->firstItem() }}{{ $parkingLots->lastItem() }}
من {{ $parkingLots->total() }}
</span>
{{ $parkingLots->appends(request()->query())->links('pagination::bootstrap-5') }}
</div>
@endif
@endif
{{-- ── Add / Edit Modal ────────────────────────────────────────────────────── --}} {{-- ── Add / Edit Modal ────────────────────────────────────────────────────── --}}
<div class="modal fade" id="lotModal" tabindex="-1" data-bs-backdrop="static"> <div class="modal fade" id="lotModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"> <div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header" style="border-bottom:1px solid #f1f5f9;">
<h5 class="modal-title fw-700" id="modalLabel" style="font-size:1rem;color:#0f172a;">
إضافة موقف جديد
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="lotForm"> <form id="lotForm">
@csrf @csrf
<input type="hidden" id="lotId"> <input type="hidden" id="lotId">
<div class="modal-body p-4"> <div class="modal-body p-4">
<p id="modalLabel" class="fw-700 mb-3" style="font-size:1rem;color:#0f172a;">إضافة موقف جديد</p>
<div class="row g-3"> <div class="row g-3">
{{-- Basic Info --}} {{-- Basic Info --}}
@ -273,7 +347,7 @@
<button type="button" class="btn btn-sm fw-600" <button type="button" class="btn btn-sm fw-600"
style="background:#f1f5f9;color:#475569;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;" style="background:#f1f5f9;color:#475569;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;"
data-bs-dismiss="modal">إلغاء</button> data-bs-dismiss="modal">إلغاء</button>
<button type="submit" id="submitBtn" <button type="button" id="submitBtn" onclick="saveLot()"
class="btn btn-sm fw-600" class="btn btn-sm fw-600"
style="background:#6366f1;color:#fff;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;"> style="background:#6366f1;color:#fff;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;">
<span id="submitSpinner" class="spinner-border spinner-border-sm me-1 d-none"></span> <span id="submitSpinner" class="spinner-border spinner-border-sm me-1 d-none"></span>
@ -285,59 +359,142 @@
</div> </div>
</div> </div>
{{-- ── Pricing Modal ──────────────────────────────────────────────────────── --}}
<div class="modal fade" id="pricingModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered" style="max-width:520px;">
<div class="modal-content">
<div class="modal-header" style="border-bottom:1px solid #f1f5f9;">
<h5 class="modal-title fw-700" id="pricingModalLabel" style="font-size:1rem;color:#0f172a;">
<i class="bi bi-tags me-1" style="color:#6366f1;"></i>
التسعير الأسبوعي
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-4">
<input type="hidden" id="pricingLotId">
{{-- Base rate --}}
<div class="mb-4">
<label class="form-label fw-600">
السعر الأساسي (لجميع الأيام غير المخصصة)
<span style="color:#ef4444;">*</span>
</label>
<div class="input-group">
<input type="number" id="p_base" class="form-control"
step="0.01" min="0" placeholder="0.00"
oninput="syncBaseHints()">
<span class="input-group-text" style="font-family:'Cairo',sans-serif;background:#f8fafc;color:#64748b;border-color:#e2e8f0;">ر.س / ساعة</span>
</div>
<p class="text-xs mt-1 mb-0" style="color:#94a3b8;">
الأيام التي لا تحمل سعراً مخصصاً ستستخدم هذا السعر تلقائياً.
</p>
</div>
{{-- Per-day rules --}}
<p class="text-xs fw-700 text-uppercase mb-3" style="color:#94a3b8;letter-spacing:.05em;">
أسعار مخصصة حسب اليوم <span class="fw-400 text-lowercase" style="color:#b0bec5;">(اختياري)</span>
</p>
<div id="dayRulesGrid">
@php
$days = [
1 => ['الاثنين', 'Monday', '#6366f1'],
2 => ['الثلاثاء', 'Tuesday', '#6366f1'],
3 => ['الأربعاء', 'Wednesday', '#6366f1'],
4 => ['الخميس', 'Thursday', '#6366f1'],
5 => ['الجمعة', 'Friday', '#f59e0b'],
6 => ['السبت', 'Saturday', '#10b981'],
7 => ['الأحد', 'Sunday', '#10b981'],
];
@endphp
@foreach($days as $dow => [$ar, $en, $color])
<div class="d-flex align-items-center gap-3 py-2"
style="border-bottom:1px solid #f1f5f9;">
<div style="width:90px;flex-shrink:0;">
<span class="fw-600 text-sm" style="color:#0f172a;">{{ $ar }}</span>
<div class="text-xs" style="color:#94a3b8;">{{ $en }}</div>
</div>
<div class="flex-grow-1">
<div class="input-group input-group-sm">
<input type="number"
id="p_day_{{ $dow }}"
class="form-control day-rate-input"
data-dow="{{ $dow }}"
step="0.01" min="0"
placeholder="مثل السعر الأساسي"
style="font-family:'Cairo',sans-serif;">
<span class="input-group-text" style="background:#f8fafc;color:#94a3b8;border-color:#e2e8f0;font-size:.75rem;">ر.س</span>
</div>
</div>
<div style="width:70px;text-align:center;">
<span id="p_badge_{{ $dow }}"
class="badge d-none"
style="font-size:.65rem;background:rgba(99,102,241,.1);color:#6366f1;border-radius:.3rem;">
مخصص
</span>
</div>
<button type="button"
class="btn btn-sm p-0"
style="color:#94a3b8;border:none;background:none;font-size:.75rem;"
onclick="clearDayRate({{ $dow }})"
title="استخدام السعر الأساسي">
<i class="bi bi-x-circle"></i>
</button>
</div>
@endforeach
</div>
{{-- Weekly preview --}}
<div class="mt-4 p-3" style="background:#f8fafc;border-radius:.5rem;border:1px solid #e2e8f0;">
<p class="text-xs fw-700 mb-2" style="color:#64748b;">معاينة التسعير الأسبوعي</p>
<div id="pricingPreview" class="d-flex gap-1 flex-wrap">
@foreach($days as $dow => [$ar, $en, $color])
<div id="prev_{{ $dow }}"
class="text-center"
style="flex:1;min-width:52px;background:#fff;border:1px solid #e2e8f0;border-radius:.375rem;padding:.35rem .25rem;">
<div class="text-xs fw-600" style="color:#475569;">{{ $ar }}</div>
<div id="prev_val_{{ $dow }}"
class="fw-700 mt-1"
style="font-size:.78rem;color:#6366f1;"></div>
</div>
@endforeach
</div>
<p class="text-xs mt-2 mb-0" style="color:#94a3b8;">
السعر المعروض هو السعر الفعلي الذي سيُطبّق على كل يوم.
الحجوزات القائمة حالياً <strong>لن تتأثر</strong> بأي تغيير.
</p>
</div>
</div>
<div class="modal-footer" style="border-top:1px solid #f1f5f9;">
<button type="button" class="btn btn-sm fw-600"
style="background:#f1f5f9;color:#475569;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;"
data-bs-dismiss="modal">إلغاء</button>
<button type="button" id="savePricingBtn"
class="btn btn-sm fw-600"
style="background:#6366f1;color:#fff;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;"
onclick="savePricing()">
<span id="pricingSpinner" class="spinner-border spinner-border-sm me-1 d-none"></span>
<i class="bi bi-check2 me-1"></i>
حفظ الأسعار
</button>
</div>
</div>
</div>
</div>
@push('scripts') @push('scripts')
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script> <script>
window.onerror = (msg, src, line) => { alert('JS خطأ في السطر ' + line + ':\n' + msg); };
// ── Parking lots data from server ───────────────────────────────────────── // ── Parking lots data from server ─────────────────────────────────────────
const lotsData = @json($parkingLots->items()); const lotsData = @json($parkingLots->items());
// Damascus city centre fallback // Damascus city centre fallback
const DAMASCUS = [33.5138, 36.2765]; const DAMASCUS = [33.5138, 36.2765];
// ── Overview map ──────────────────────────────────────────────────────────
const lotsMap = L.map('lotsMap').setView(DAMASCUS, 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19
}).addTo(lotsMap);
const activeIcon = L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34]
});
lotsData.forEach(lot => {
const lat = parseFloat(lot.latitude);
const lng = parseFloat(lot.longitude);
if (isNaN(lat) || isNaN(lng)) return;
const statusBadge = lot.is_active
? '<span style="color:#059669;font-weight:700;">● نشط</span>'
: '<span style="color:#dc2626;font-weight:700;">● معطل</span>';
L.marker([lat, lng], { icon: activeIcon })
.addTo(lotsMap)
.bindPopup(`
<div style="font-family:'Cairo',sans-serif;min-width:160px;direction:rtl;">
<div style="font-weight:700;font-size:.92rem;margin-bottom:4px;">${lot.name}</div>
<div style="font-size:.8rem;color:#475569;margin-bottom:4px;">${lot.address ?? ''}</div>
<div style="font-size:.8rem;margin-bottom:6px;">${statusBadge}</div>
<div style="font-size:.78rem;color:#64748b;">
السعة: ${lot.total_capacity} | ${lot.price_per_hour} ر.س/س
</div>
</div>
`, { maxWidth: 220 });
});
// Fit map to markers if any exist
const validLots = lotsData.filter(l => !isNaN(parseFloat(l.latitude)) && !isNaN(parseFloat(l.longitude)));
if (validLots.length > 0) {
const bounds = L.latLngBounds(validLots.map(l => [parseFloat(l.latitude), parseFloat(l.longitude)]));
lotsMap.fitBounds(bounds, { padding: [40, 40], maxZoom: 15 });
}
// ── Modal map picker ────────────────────────────────────────────────────── // ── Modal map picker ──────────────────────────────────────────────────────
let modalMap = null; let modalMap = null;
let modalMarker = null; let modalMarker = null;
@ -388,8 +545,10 @@ function placeModalMarker(lat, lng) {
} }
// Sync map marker when user types into lat/lng fields // Sync map marker when user types into lat/lng fields
['f_lat', 'f_lng'].forEach(id => { ['f_lat', 'f_lng'].forEach(elId => {
document.getElementById(id).addEventListener('input', () => { const el = document.getElementById(elId);
if (!el) return;
el.addEventListener('input', () => {
const lat = parseFloat(document.getElementById('f_lat').value); const lat = parseFloat(document.getElementById('f_lat').value);
const lng = parseFloat(document.getElementById('f_lng').value); const lng = parseFloat(document.getElementById('f_lng').value);
if (!isNaN(lat) && !isNaN(lng) && modalMap) { if (!isNaN(lat) && !isNaN(lng) && modalMap) {
@ -409,7 +568,6 @@ lotModalEl.addEventListener('shown.bs.modal', () => {
// ── Modal logic ─────────────────────────────────────────────────────────── // ── Modal logic ───────────────────────────────────────────────────────────
let editingId = null; let editingId = null;
const modal = new bootstrap.Modal(lotModalEl, { backdrop: 'static' });
document.getElementById('addLotBtn').onclick = () => { document.getElementById('addLotBtn').onclick = () => {
editingId = null; editingId = null;
@ -417,7 +575,7 @@ document.getElementById('addLotBtn').onclick = () => {
document.getElementById('modalLabel').textContent = 'إضافة موقف جديد'; document.getElementById('modalLabel').textContent = 'إضافة موقف جديد';
document.getElementById('submitText').textContent = 'حفظ الموقف'; document.getElementById('submitText').textContent = 'حفظ الموقف';
document.getElementById('imagePreviewWrap').style.display = 'none'; document.getElementById('imagePreviewWrap').style.display = 'none';
modal.show(); // modal opened by data-bs-toggle on the button
}; };
function previewImage(input) { function previewImage(input) {
@ -431,50 +589,57 @@ function previewImage(input) {
} }
} }
async function editLot(id) { function editLot(id) {
const lot = lotsData.find(l => l.id === id);
if (!lot) { alert('لم يتم العثور على بيانات الموقف'); return; }
editingId = id; editingId = id;
setBtnLoading(true); document.getElementById('modalLabel').textContent = 'تعديل: ' + lot.name;
try { document.getElementById('submitText').textContent = 'تحديث الموقف';
const { data } = await fetch(`/admin/parking-lots/${id}`).then(r => r.json()); document.getElementById('f_name').value = lot.name;
document.getElementById('modalLabel').textContent = 'تعديل: ' + data.name; document.getElementById('f_capacity').value = lot.total_capacity;
document.getElementById('submitText').textContent = 'تحديث الموقف'; document.getElementById('f_price').value = lot.price_per_hour;
document.getElementById('f_name').value = data.name; document.getElementById('f_hours').value = lot.working_hours;
document.getElementById('f_capacity').value = data.total_capacity; document.getElementById('f_lat').value = lot.latitude;
document.getElementById('f_price').value = data.price_per_hour; document.getElementById('f_lng').value = lot.longitude;
document.getElementById('f_hours').value = data.working_hours; document.getElementById('f_address').value = lot.address;
document.getElementById('f_lat').value = data.latitude; document.getElementById('f_image').value = '';
document.getElementById('f_lng').value = data.longitude;
document.getElementById('f_address').value = data.address; const wrap = document.getElementById('imagePreviewWrap');
document.getElementById('f_image').value = ''; const img = document.getElementById('imagePreview');
// Show existing image preview if (lot.image) {
const wrap = document.getElementById('imagePreviewWrap'); img.src = '/storage/' + lot.image;
const img = document.getElementById('imagePreview'); wrap.style.display = 'block';
if (data.image) { } else {
img.src = '/storage/' + data.image; wrap.style.display = 'none';
wrap.style.display = 'block'; }
} else { // modal opened by data-bs-toggle on the button
wrap.style.display = 'none';
}
modal.show();
} catch { alert('خطأ في تحميل البيانات'); }
finally { setBtnLoading(false); }
} }
document.getElementById('lotForm').onsubmit = async (e) => { async function saveLot() {
e.preventDefault();
setBtnLoading(true); setBtnLoading(true);
try { try {
const fd = new FormData(e.target); const form = document.getElementById('lotForm');
const fd = new FormData(form);
if (editingId) fd.append('_method', 'PUT'); if (editingId) fd.append('_method', 'PUT');
const res = await fetch(editingId ? `/admin/parking-lots/${editingId}` : '/admin/parking-lots', { const url = editingId ? `/admin/parking-lots/${editingId}` : '/admin/parking-lots';
const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
body: fd body: fd
}); });
const result = await res.json(); const result = await res.json();
result.success ? location.reload() : alert(result.message || 'خطأ في العملية'); if (result.success) {
} catch { alert('خطأ في الاتصال'); } location.reload();
} else if (result.errors) {
const msgs = Object.values(result.errors).flat().join('\n');
alert(msgs);
} else {
alert(result.message || 'خطأ في العملية');
}
} catch(err) { alert('خطأ في الاتصال: ' + err.message); }
finally { setBtnLoading(false); } finally { setBtnLoading(false); }
}; }
function setBtnLoading(on) { function setBtnLoading(on) {
document.getElementById('submitBtn').disabled = on; document.getElementById('submitBtn').disabled = on;
@ -497,6 +662,109 @@ function doSearch() {
window.location.href = `/admin/parking-lots?search=${encodeURIComponent(t)}`; window.location.href = `/admin/parking-lots?search=${encodeURIComponent(t)}`;
} }
// ── Pricing Modal ─────────────────────────────────────────────────────────
function openPricingModal(id, name) {
try {
const lot = lotsData.find(l => l.id === id);
const base = lot ? (parseFloat(lot.price_per_hour) || 0) : 0;
const rules = (lot && lot.pricing_rules) ? lot.pricing_rules : {};
document.getElementById('pricingLotId').value = id;
document.getElementById('pricingModalLabel').innerHTML =
`<i class="bi bi-tags me-1" style="color:#6366f1;"></i>التسعير الأسبوعي — ${name}`;
document.getElementById('p_base').value = base;
[1,2,3,4,5,6,7].forEach(d => {
const custom = rules[d] !== undefined ? parseFloat(rules[d]) : null;
document.getElementById(`p_day_${d}`).value = (custom !== null) ? custom : '';
document.getElementById(`p_badge_${d}`).classList.add('d-none');
});
updatePricingPreview();
bootstrap.Modal.getOrCreateInstance(document.getElementById('pricingModal')).show();
} catch(e) {
alert('خطأ في فتح نافذة التسعير:\n' + e.message);
}
}
function syncBaseHints() {
updatePricingPreview();
}
function clearDayRate(dow) {
document.getElementById(`p_day_${dow}`).value = '';
updatePricingPreview();
}
function updatePricingPreview() {
const base = parseFloat(document.getElementById('p_base').value) || 0;
[1,2,3,4,5,6,7].forEach(d => {
const raw = document.getElementById(`p_day_${d}`).value;
const custom = raw !== '' ? parseFloat(raw) : null;
const rate = custom !== null ? custom : base;
const isCustom = custom !== null && custom !== base;
document.getElementById(`p_badge_${d}`).classList.toggle('d-none', !isCustom);
const valEl = document.getElementById(`prev_val_${d}`);
valEl.textContent = rate > 0 ? rate.toLocaleString('ar-SA') : '—';
valEl.style.color = isCustom ? '#f59e0b' : '#6366f1';
document.getElementById(`prev_${d}`).style.borderColor = isCustom ? '#fbbf24' : '#e2e8f0';
});
}
// Update preview when any day input changes
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.day-rate-input').forEach(inp => {
inp.addEventListener('input', updatePricingPreview);
});
});
async function savePricing() {
const id = document.getElementById('pricingLotId').value;
const base = parseFloat(document.getElementById('p_base').value);
if (isNaN(base) || base < 0) {
alert('يرجى إدخال سعر أساسي صحيح');
return;
}
const rules = {};
[1,2,3,4,5,6,7].forEach(d => {
const val = document.getElementById(`p_day_${d}`).value;
if (val !== '') rules[d] = parseFloat(val);
});
document.getElementById('savePricingBtn').disabled = true;
document.getElementById('pricingSpinner').classList.remove('d-none');
try {
const res = await fetch(`/admin/parking-lots/${id}/pricing`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({ price_per_hour: base, pricing_rules: rules }),
});
const data = await res.json();
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('pricingModal'))?.hide();
location.reload();
} else {
alert(data.message || 'خطأ في الحفظ');
}
} catch {
alert('خطأ في الاتصال');
} finally {
document.getElementById('savePricingBtn').disabled = false;
document.getElementById('pricingSpinner').classList.add('d-none');
}
}
async function deleteLot(id, name) { async function deleteLot(id, name) {
if (!confirm(`حذف الموقف "${name}"؟\nسيتم حذف جميع بيانات الموقف نهائياً.`)) return; if (!confirm(`حذف الموقف "${name}"؟\nسيتم حذف جميع بيانات الموقف نهائياً.`)) return;

View File

@ -28,6 +28,7 @@ Route::prefix('admin')->middleware('admin')->name('admin.')->group(function () {
Route::get('/parking-lots/{parkingLot}', [\App\Http\Controllers\Admin\ParkingLotController::class, 'show'])->name('parking-lots.show'); Route::get('/parking-lots/{parkingLot}', [\App\Http\Controllers\Admin\ParkingLotController::class, 'show'])->name('parking-lots.show');
Route::post('/parking-lots', [\App\Http\Controllers\Admin\ParkingLotController::class, 'store'])->name('parking-lots.store'); Route::post('/parking-lots', [\App\Http\Controllers\Admin\ParkingLotController::class, 'store'])->name('parking-lots.store');
Route::put('/parking-lots/{parkingLot}', [\App\Http\Controllers\Admin\ParkingLotController::class, 'update'])->name('parking-lots.update'); Route::put('/parking-lots/{parkingLot}', [\App\Http\Controllers\Admin\ParkingLotController::class, 'update'])->name('parking-lots.update');
Route::put('/parking-lots/{parkingLot}/pricing', [\App\Http\Controllers\Admin\ParkingLotController::class, 'updatePricing'])->name('parking-lots.pricing');
Route::post('/parking-lots/{parkingLot}/toggle', [\App\Http\Controllers\Admin\ParkingLotController::class, 'toggleStatus'])->name('parking-lots.toggle'); Route::post('/parking-lots/{parkingLot}/toggle', [\App\Http\Controllers\Admin\ParkingLotController::class, 'toggleStatus'])->name('parking-lots.toggle');
Route::delete('/parking-lots/{parkingLot}', [\App\Http\Controllers\Admin\ParkingLotController::class, 'destroy'])->name('parking-lots.destroy'); Route::delete('/parking-lots/{parkingLot}', [\App\Http\Controllers\Admin\ParkingLotController::class, 'destroy'])->name('parking-lots.destroy');