initial: project state before operator dashboard card redesign

This commit is contained in:
Ghassan Yusuf 2026-04-15 11:39:10 +03:00
commit e0aed8eed7
113 changed files with 20074 additions and 0 deletions

5
.claude/settings.json Normal file
View File

@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}

View File

@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(npm run:*)",
"WebFetch(domain:video.takeone.bh)",
"Bash(php artisan:*)",
"Bash(php -l app/Http/Controllers/ProfileController.php)",
"Bash(php -l app/Models/Booking.php)",
"Bash(php -l app/Http/Controllers/Operator/OperatorController.php)",
"Bash(php -l app/Models/ParkingLot.php)",
"Bash(git init:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
]
}
}

18
.editorconfig Normal file
View File

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

45
.env.example Normal file
View File

@ -0,0 +1,45 @@
APP_NAME="Damascus Parking"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=Asia/Damascus
APP_URL=http://localhost:8000
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
DB_DATABASE="c:/Users/Ghassan Yusuf/Desktop/scp-syria/database/database.sqlite"
BROADCAST_CONNECTION=log
CACHE_STORE=database
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=database
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View File

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db

332
CLAUDE.md Normal file
View File

@ -0,0 +1,332 @@
# CLAUDE.md — Damascus Parking (SCP-Syria)
## Working Rules
### Mandatory: Branch Before Every Change
**This is a hard rule. Never modify any file without first creating a git branch and committing the current state.**
1. Create a branch named after the feature or fix being worked on
2. Stage and commit every file that will be touched — this is the rollback point
3. Do **not** push to remote
4. Then make the changes on that same branch
```bash
git checkout -b feature/my-feature-name
git add <files being changed>
git commit -m "backup: before <description of change>"
# now make the changes
```
To revert a file: `git checkout <branch>^ -- path/to/file`
To revert everything: `git checkout <branch>^`
---
## Project Overview
**Damascus Parking** (دمشق باركينغ) is a full-stack web application for parking lot management in Damascus, Syria. It allows the public to search and book parking spaces, operators to manage vehicle check-ins/check-outs, and admins to oversee all operations.
- **Framework:** Laravel 12.0 (PHP ^8.2)
- **Database:** SQLite (`database/database.sqlite`)
- **Auth:** Laravel Sanctum 4.0 (session-based for web, token-based for API)
- **Frontend:** Vite 7 + Bootstrap 5.3 + Sass + Leaflet maps
- **Language/Layout:** Arabic (RTL throughout), font: Cairo (Google Fonts)
- **Timezone:** Asia/Damascus
- **Local URL:** http://localhost:8000
---
## Development Setup
```bash
# Full install + migrate + build
composer setup
# Start all dev services (server, queue, logs, Vite HMR)
composer dev
# OR individually:
php artisan serve
npm run dev
# Run tests
composer test
# or: php artisan config:clear && php artisan test
```
**Vite entry points:** `resources/css/app.scss`, `resources/js/app.js`
---
## Architecture
### Directory Structure
```
app/
Http/
Controllers/
Admin/ # Admin-only: Dashboard, ParkingLot, Booking
Api/ # REST API: ParkingLot, Booking, CarRegistry
Operator/ # Operator-only: OperatorController
ParkingController.php # Public landing page
Middleware/
EnsureAdmin.php
EnsureOperator.php
Requests/ # Form validation: Booking, CheckIn, ParkingLot
Resources/ # API response transformers
Models/
User.php # Roles: admin | operator | user
ParkingLot.php # Scopes: active(), withStatus(); computed attributes
Booking.php # Status enum: active | completed | cancelled
CarRegistry.php # Scope: active() (exit_time IS NULL)
routes/
web.php # Pages + auth
api.php # /api/v1/* REST endpoints
auth.php # Login/register/logout
resources/
views/
admin/ # Admin Blade templates
operator/ # Operator Blade templates
auth/ # Login & register pages
layouts/ # Base layouts
index.blade.php # Public landing page
css/app.scss # ~780 lines of custom RTL-aware styles
js/app.js # Bootstrap + Axios setup
```
### Roles & Access
| Role | Middleware | Access |
|------------|-----------------|---------------------------------------------|
| `admin` | `EnsureAdmin` | Full admin dashboard, parking lot CRUD, all bookings |
| `operator` | `EnsureOperator`| Vehicle check-in/check-out, active bookings for assigned lot |
| `user` | (auth only) | Public search, create bookings via API |
Both middleware classes return Arabic 403 messages on unauthorized access.
---
## Database Schema
### `users`
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| name | string | |
| email | string unique | |
| password | string | bcrypt |
| role | string | 'admin' \| 'operator' \| 'user' (default) |
### `parking_lots`
| Column | Type | Notes |
|--------|------|-------|
| id | bigint PK | |
| name | string | |
| address | string | |
| total_capacity | integer | |
| price_per_hour | decimal | |
| latitude / longitude | decimal | Used by Leaflet map |
| working_hours | string | Default '24/7' |
| is_active | boolean | Toggleable by admin |
Computed attributes (not stored): `available_spaces`, `occupied_spaces`, `usage_percentage`
### `bookings`
| Column | Type | Notes |
|--------|------|-------|
| parking_lot_id | FK | |
| customer_name | string | |
| phone | string | Format: `09xxxxxxxx` |
| start_time / end_time | datetime | |
| status | enum | 'active' \| 'completed' \| 'cancelled' |
### `car_registries`
| Column | Type | Notes |
|--------|------|-------|
| parking_lot_id | FK | |
| plate_number | string | |
| entry_time | datetime | |
| exit_time | datetime nullable | NULL = currently parked |
---
## API Reference (`/api/v1`)
All responses follow: `{ "success": bool, "data": ..., "message": "..." }`
### Parking Lots
```
GET /api/v1/parking-lots # Paginated list with occupancy status
GET /api/v1/parking-lots/{id} # Single lot details
GET /api/v1/parking-lots/search?q= # Search by name/address
GET /api/v1/parking-lots/{id}/status # Real-time occupancy
```
### Bookings
```
POST /api/v1/bookings # Create booking
GET /api/v1/bookings # Recent bookings (paginated)
```
Required fields: `parking_lot_id`, `customer_name`, `phone` (09xxxxxxxx), `start_time`, `end_time`
### Car Registry (Check-in/Check-out)
```
POST /api/v1/car-registries # Register car entry
PUT /api/v1/car-registries/{id}/exit # Register car exit
```
Check-in requires: `parking_lot_id`, `vehicle_plate`, `duration_hours` (0.2524)
---
## Web Routes Summary
| Path | Middleware | Description |
|------|-----------|-------------|
| `GET /` | — | Public landing page |
| `GET /login` | guest | Login form |
| `GET /register` | guest | Register form |
| `POST /logout` | auth | Logout |
| `GET /admin/dashboard` | admin | Admin stats & charts |
| `GET /admin/parking-lots` | admin | List/manage parking lots |
| `POST /admin/parking-lots` | admin | Create parking lot |
| `PUT /admin/parking-lots/{id}` | admin | Update parking lot |
| `POST /admin/parking-lots/{id}/toggle` | admin | Toggle active status |
| `GET /admin/bookings/active` | admin | Active bookings |
| `POST /admin/bookings/{id}/complete` | admin | Mark booking complete |
| `GET /operator/dashboard` | operator | Operator view |
| `POST /operator/check-in` | operator | Vehicle entry |
| `POST /operator/{booking}/checkout` | operator | Vehicle exit + fee |
| `GET /admin/stats` | admin | JSON stats (AJAX) |
| `GET /admin/charts` | admin | JSON chart data (AJAX) |
---
## Validation Rules
**Phone:** must match `^09[0-9]{8}$` (Syrian mobile format)
**Booking:**
- `start_time`: required, after:now
- `end_time`: required, after:start_time
- Custom: parking lot must have available capacity
**Parking Lot:**
- `total_capacity`: integer, 110000
- `price_per_hour`: numeric, 0.011000
- `latitude`: -90 to 90, `longitude`: -180 to 180
**Operator Check-in:**
- `duration_hours`: numeric, 0.2524
---
## Frontend & Styling
**Color palette (CSS variables in `app.scss`):**
- Primary: `#6366f1` (indigo)
- Sidebar bg: `#0f172a`
- Muted text: `#64748b`
- Page bg: `#f1f5f9`
**RTL-specific patterns:**
- `direction: rtl` on `<html>`
- Input groups reverse border-radius via CSS overrides in `[dir="rtl"]` block in `app.scss`
- Flexbox rows appear mirrored naturally; no special JS needed
- Margin/padding use `inset-inline-*` where needed
**Bootstrap RTL spacing — critical rule:**
Bootstrap is imported as SCSS (`@import "bootstrap/scss/bootstrap"`), which compiles `me-*` to physical `margin-right` and `ms-*` to `margin-left`. In RTL, icons sit on the **right** and text on the **left**, so `margin-right` on an icon pushes *away* from the text — the gap appears on the wrong side.
**The fix** is already applied in the `[dir="rtl"]` block at the bottom of `app.scss`: it swaps all `me-*`/`ms-*` physical margins so they behave like Bootstrap's logical properties.
**Rule: never add `me-*` to fix icon-to-text spacing — it already works correctly via the global `[dir="rtl"]` override. Do not remove or change those overrides.** If a new icon appears stuck to its label, the cause is always the same: Bootstrap's LTR SCSS compilation. The fix is already in place globally — just use the standard Bootstrap spacing classes (`me-1`, `me-2`, etc.) and they will work correctly in RTL.
**Responsive breakpoints:**
- `<768px`: sidebar hidden, bottom nav visible (56px)
- `768px992px`: sidebar can toggle
- `>992px`: full sidebar + content layout
**Libraries:**
- Leaflet 1.9.4 — interactive parking lot map
- Bootstrap Icons 1.11.3 — icon set
- Axios 1.11.0 — AJAX calls from admin/operator JS
---
## Fee Calculation Logic
```php
$duration = ceil(now()->diffInHours($entry_time)); // rounds up
$fee = $duration * $parking_lot->price_per_hour;
```
Fees are always rounded up to the nearest hour.
---
## Availability Calculation
Available spaces = `total_capacity` (active bookings count + active car registries count)
`CarRegistry::active()` scope: `whereNull('exit_time')` (or `exit_time > now()`)
`Booking::active()` scope: `where('status', 'active')`
Overbooking is prevented at API validation time in `StoreBookingRequest`.
---
## Key Artisan Commands
```bash
php artisan migrate # Run migrations
php artisan migrate:fresh --seed # Reset DB and seed
php artisan tinker # REPL for debugging
php artisan route:list # List all routes
php artisan config:clear # Clear config cache
php artisan make:model Foo -mcr # Model + migration + controller + resource
```
---
## Testing
Tests live in `tests/Feature/` and `tests/Unit/`. PHPUnit 11 is configured in `phpunit.xml`. The test database uses the SQLite in-memory driver by default.
```bash
composer test
# or
php artisan config:clear && php artisan test
```
---
## User-Facing Pages (authenticated)
| Route | Name | Description |
|-------|------|-------------|
| `GET /profile` | `profile.show` | User info + edit name + change password |
| `PATCH /profile` | `profile.update` | Update name (error bag: `updateName`) |
| `PATCH /profile/password` | `profile.password` | Change password (error bag: `updatePassword`) |
| `GET /dashboard` | `user.dashboard` | User's booking history + stats |
**Profile dropdown** is a Blade partial at `resources/views/partials/user-dropdown.blade.php`. It is included in both `layouts/user.blade.php` and `index.blade.php`. When the user is a guest it shows the login button; when authenticated it shows an avatar circle with a dropdown containing profile, reservations, operator/admin links (role-gated), and sign-out.
**Post-login redirect** (in `routes/auth.php`) is role-based:
- `admin``/admin/dashboard`
- `operator``/operator/dashboard`
- `user``/dashboard`
**`bookings.user_id`** — nullable FK added via migration `2026_04_15_072226`. Existing seeded bookings have `user_id = NULL`. New bookings made by a logged-in user should set `user_id = Auth::id()`.
---
## Notes & Known Patterns
- Admin stats (`/admin/stats`, `/admin/charts`) are fetched via AJAX on page load — not cached, computed fresh each request.
- The `CarRegistry` model tracks physical vehicle presence; `Booking` tracks reservations — they are separate but both count toward capacity.
- `StoreParkingLotRequest` and `StoreBookingRequest` are in `app/Http/Requests/`.
- Arabic error messages are returned by both middleware and validation responses.
- The `TODO.md` at the root is nearly empty — "Update with checked" is the only entry.
- Some API routes have commented-out `auth:sanctum` middleware; add it back before deploying to production.

59
README.md Normal file
View File

@ -0,0 +1,59 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

1
TODO.md Normal file
View File

@ -0,0 +1 @@
Update with checked

View File

@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\ParkingLot;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class BookingController extends Controller
{
public function activeIndex(Request $request): \Illuminate\View\View
{
$parkingLotId = $request->get('parking_lot_id');
$parkingLots = ParkingLot::active()->get(['id', 'name']);
$query = Booking::with('parkingLot')
->where('status', 'active')
->orderBy('start_time', 'desc');
if ($parkingLotId) {
$query->where('parking_lot_id', $parkingLotId);
}
$activeBookings = $query->paginate(50);
return view('admin.bookings.active', compact('activeBookings', 'parkingLots', 'parkingLotId'));
}
public function completeBooking(Request $request, Booking $booking): JsonResponse
{
if ($booking->status !== 'active') {
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()
]);
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
]
]);
}
}
?>

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Booking;
use App\Models\ParkingLot;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class DashboardController extends Controller
{
public function index()
{
return view('admin.dashboard');
}
public function statsJson(Request $request)
{
// Total metrics
$totalParkingLots = ParkingLot::count();
$totalBookings = Booking::count();
// Active bookings (assume status = 'active'; adjust if using time overlap)
$activeBookings = Booking::where('status', 'active')->count();
// Occupancy rate: (active bookings / total capacity) * 100
$totalCapacity = ParkingLot::sum('total_capacity');
$occupancyRate = $totalCapacity > 0 ? round(($activeBookings / $totalCapacity) * 100, 1) : 0;
// Estimated revenue: sum price_per_hour for all active bookings
$revenue = Booking::where('status', 'active')
->join('parking_lots', 'bookings.parking_lot_id', '=', 'parking_lots.id')
->sum('parking_lots.price_per_hour');
// Available spots (total capacity - active)
$availableSpots = $totalCapacity - $activeBookings;
return response()->json([
'success' => true,
'data' => [
'total_parking_lots' => $totalParkingLots,
'total_bookings' => $totalBookings,
'active_bookings' => $activeBookings,
'occupancy_rate' => $occupancyRate,
'estimated_revenue' => round($revenue, 2),
'available_spots' => $availableSpots,
]
]);
}
public function chartsJson(Request $request)
{
$now = Carbon::now();
// Daily bookings last 7 days
$dailyBookings = Booking::selectRaw('DATE(created_at) as date, COUNT(*) as count')
->whereBetween('created_at', [$now->clone()->subDays(7), $now])
->groupBy('date')
->orderBy('date')
->pluck('count', 'date')
->toArray();
// Fill missing days
$dates = [];
for ($i = 6; $i >= 0; $i--) {
$date = $now->clone()->subDays($i)->format('Y-m-d');
$dates[$date] = $dailyBookings[$date] ?? 0;
}
ksort($dates);
// Top 5 parking lots by total bookings
$topLots = ParkingLot::withCount('bookings')
->orderBy('bookings_count', 'desc')
->take(5)
->get(['id', 'name', 'bookings_count']);
// Occupancy trend: current vs previous day (simplified)
$todayActive = Booking::where('status', 'active')->whereDate('created_at', $now->format('Y-m-d'))->count();
$yesterdayActive = Booking::where('status', 'active')->whereDate('created_at', $now->clone()->subDay()->format('Y-m-d'))->count();
$trend = $todayActive - $yesterdayActive; // positive growth
return response()->json([
'success' => true,
'data' => [
'daily_bookings' => array_values($dates),
'top_parking_lots' => $topLots->map(fn($lot) => ['name' => $lot->name, 'value' => $lot->bookings_count])->toArray(),
'occupancy_trend' => $trend,
]
]);
}
}
?>

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreParkingLotRequest;
use App\Models\ParkingLot;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class ParkingLotController extends Controller
{
public function index(Request $request): \Illuminate\View\View
{
$parkingLots = ParkingLot::withCount(['bookings as active_bookings_count' => fn($q) => $q->where('status', 'active')])
->when($request->search, fn($q) => $q->search($request->search))
->orderBy('created_at', 'desc')
->paginate(20);
return view('admin.parking-lots.index', compact('parkingLots'));
}
public function show(ParkingLot $parkingLot): JsonResponse
{
$parkingLot->loadCount(['bookings as active_bookings_count' => fn($q) => $q->where('status', 'active')]);
return response()->json([
'success' => true,
'data' => $parkingLot
]);
}
public function store(StoreParkingLotRequest $request): JsonResponse
{
$parkingLot = ParkingLot::create($request->validated());
return response()->json([
'success' => true,
'message' => 'تم إضافة موقف السيارات بنجاح',
'data' => $parkingLot->loadCount(['bookings as active_bookings_count' => fn($q) => $q->where('status', 'active')])
]);
}
public function update(StoreParkingLotRequest $request, ParkingLot $parkingLot): JsonResponse
{
$parkingLot->update($request->validated());
return response()->json([
'success' => true,
'message' => 'تم تحديث موقف السيارات بنجاح',
'data' => $parkingLot->loadCount(['bookings as active_bookings_count' => fn($q) => $q->where('status', 'active')])
]);
}
public function toggleStatus(ParkingLot $parkingLot): JsonResponse
{
$parkingLot->update(['is_active' => !$parkingLot->is_active]);
return response()->json([
'success' => true,
'message' => $parkingLot->is_active ? 'تم تفعيل الموقف' : 'تم إلغاء تفعيل الموقف',
'data' => ['is_active' => $parkingLot->is_active]
]);
}
}
?>

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreBookingRequest;
use App\Http\Resources\BookingResource;
use App\Models\Booking;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class BookingController extends Controller
{
/**
* Handle the incoming request.
*/
public function __invoke(StoreBookingRequest $request)
{
$validated = $request->validated();
$booking = \App\Models\Booking::create($validated);
// Note: In production, create CarRegistry here for the booking car
return response()->json([
'success' => true,
'data' => new BookingResource($booking),
'message' => 'Booking created successfully',
], 201);
}
public function index(Request $request)
{
$bookings = Booking::with('parkingLot')
->latest()
->paginate(10);
return response()->json([
'success' => true,
'data' => [
'data' => BookingResource::collection($bookings),
'current_page' => $bookings->currentPage(),
'last_page' => $bookings->lastPage(),
'per_page' => $bookings->perPage(),
'total' => $bookings->total(),
],
'message' => 'Recent bookings retrieved successfully',
]);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreOperatorCheckInRequest;
use App\Models\CarRegistry;
use App\Models\ParkingLot;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Carbon\Carbon;
class CarRegistryController extends Controller
{
public function checkIn(StoreOperatorCheckInRequest $request): JsonResponse
{
$validated = $request->validated();
$parkingLot = ParkingLot::findOrFail($validated['parking_lot_id']);
// Check capacity vs active registries
$activeCount = $parkingLot->carRegistries()->active()->count();
if ($activeCount >= $parkingLot->total_capacity) {
return response()->json([
'success' => false,
'message' => 'Parking lot is full',
], 422);
}
$registry = CarRegistry::create([
'parking_lot_id' => $validated['parking_lot_id'],
'plate_number' => $validated['vehicle_plate'],
'entry_time' => now(),
// Optional: predicted exit based on duration
'exit_time' => isset($validated['duration_hours']) ? now()->addHours($validated['duration_hours']) : null,
]);
return response()->json([
'success' => true,
'message' => 'Car entry registered successfully',
'data' => $registry->load('parkingLot'),
], 201);
}
public function checkOut(Request $request, CarRegistry $carRegistry): JsonResponse
{
if (! $carRegistry->exit_time) {
$carRegistry->update([
'exit_time' => now(),
]);
$durationHours = $carRegistry->entry_time->diffInHours(now());
$parkingLot = $carRegistry->parkingLot;
$fee = ceil($durationHours) * $parkingLot->price_per_hour;
return response()->json([
'success' => true,
'message' => 'Car exit registered successfully',
'data' => [
'duration_hours' => round($durationHours, 2),
'fee' => $fee,
'plate_number' => $carRegistry->plate_number,
],
]);
}
return response()->json([
'success' => false,
'message' => 'Car already checked out',
], 400);
}
}
?>

View File

@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ParkingLot;
use App\Http\Resources\ParkingLotResource;
use App\Models\Booking;
use App\Models\CarRegistry;
use Illuminate\Http\Request;
class ParkingLotController extends Controller
{
public function index(Request $request)
{
$query = ParkingLot::query()
->withCount([
'carRegistries as active_registries_count' => function($q) {
$q->active();
},
'bookings as active_bookings_count' => function($q) {
$q->where('status', 'active');
}
]);
$lots = $query->paginate(10);
return response()->json([
'success' => true,
'data' => [
'data' => ParkingLotResource::collection($lots),
'current_page' => $lots->currentPage(),
'last_page' => $lots->lastPage(),
'per_page' => $lots->perPage(),
'total' => $lots->total()
],
'message' => 'Parking lots retrieved successfully',
]);
}
public function search(Request $request)
{
$q = $request->get('q', '');
$query = ParkingLot::query()
->withCount([
'carRegistries as active_registries_count' => function($q) {
$q->active();
},
'bookings as active_bookings_count' => function($q) {
$q->where('status', 'active');
}
])
->search($q);
$lots = $query->paginate(10);
return response()->json([
'success' => true,
'data' => [
'data' => ParkingLotResource::collection($lots),
'current_page' => $lots->currentPage(),
'last_page' => $lots->lastPage(),
'per_page' => $lots->perPage(),
'total' => $lots->total()
],
'message' => 'Parking lots search results',
]);
}
public function show(ParkingLot $parkingLot)
{
$parkingLot->loadCount([
'carRegistries as active_registries_count' => function($q) {
$q->active();
},
'bookings as active_bookings_count' => function($q) {
$q->where('status', 'active');
}
]);
return response()->json([
'success' => true,
'data' => new ParkingLotResource($parkingLot),
'message' => 'Parking lot details retrieved',
]);
}
public function status(ParkingLot $parkingLot)
{
$activeRegistries = $parkingLot->carRegistries()->active()->count();
$activeBookings = $parkingLot->bookings()->where('status', 'active')->count();
$occupied = $activeRegistries + $activeBookings;
$available = max(0, $parkingLot->total_capacity - $occupied);
$usagePercentage = $parkingLot->total_capacity > 0 ? round(($occupied / $parkingLot->total_capacity) * 100, 2) : 0;
return response()->json([
'success' => true,
'data' => [
'id' => $parkingLot->id,
'name' => $parkingLot->name,
'total_capacity' => $parkingLot->total_capacity,
'occupied_spaces' => $occupied,
'available_spaces' => $available,
'usage_percentage' => $usagePercentage,
'active_registries_count' => $activeRegistries,
'active_bookings_count' => $activeBookings,
'status' => $available === 0 ? 'full' : ($available < $parkingLot->total_capacity * 0.2 ? 'limited' : 'available'),
'active_cars' => $parkingLot->carRegistries()->active()->pluck('plate_number')->toArray(),
'active_bookings' => $parkingLot->bookings()->where('status', 'active')->get(['id', 'customer_name', 'phone', 'start_time', 'end_time'])->toArray(),
],
'message' => 'Parking lot status retrieved successfully',
]);
}
}
?>

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -0,0 +1,198 @@
<?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\Storage;
use Carbon\Carbon;
class OperatorController extends Controller
{
// ── Lot picker + panel ──────────────────────────────────────────────────────
public function dashboard(Request $request): \Illuminate\View\View
{
$rawLots = ParkingLot::active()->withStatus()->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,
])->values();
$selectedLotId = $request->get('lot_id');
$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'
));
}
// ── 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($data['duration_hours']),
'status' => 'active',
]);
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);
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);
$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'],
],
]);
}
// ── Legacy checkout (kept for backward compatibility) ───────────────────────
public function checkOut(Request $request, Booking $booking): JsonResponse
{
return $this->processPayment($request->merge(['payment_method' => 'cash']), $booking);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers;
use App\Models\ParkingLot;
use Illuminate\Http\Request;
class ParkingController extends Controller
{
public function index()
{
$lots = ParkingLot::active()
->withStatus()
->get()
->map(fn($lot) => [
'id' => $lot->id,
'name' => $lot->name,
'address' => $lot->address,
'total' => $lot->total_capacity,
'avail' => max(0, $lot->total_capacity - $lot->active_registries_count - $lot->active_bookings_count),
'price' => (float) $lot->price_per_hour,
'hours' => $lot->working_hours ?? '24/7',
'lat' => (float) $lot->latitude,
'lng' => (float) $lot->longitude,
]);
return view('index', compact('lots'));
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use App\Models\Booking;
class ProfileController extends Controller
{
public function show()
{
return view('user.profile', ['user' => Auth::user()]);
}
public function update(Request $request)
{
$validated = $request->validateWithBag('updateName', [
'name' => 'required|string|max:255',
]);
Auth::user()->update(['name' => $validated['name']]);
return back()->with('success', 'تم تحديث الاسم بنجاح.');
}
public function updatePassword(Request $request)
{
$request->validateWithBag('updatePassword', [
'current_password' => 'required',
'password' => 'required|min:8|confirmed',
]);
if (!Hash::check($request->current_password, Auth::user()->password)) {
return back()->withErrors(
['current_password' => 'كلمة السر الحالية غير صحيحة.'],
'updatePassword'
);
}
Auth::user()->update(['password' => Hash::make($request->password)]);
return back()->with('success', 'تم تغيير كلمة السر بنجاح.');
}
public function dashboard()
{
$bookings = Booking::with('parkingLot')
->where('user_id', Auth::id())
->latest()
->get();
$stats = [
'total' => $bookings->count(),
'active' => $bookings->where('status', 'active')->count(),
'completed' => $bookings->where('status', 'completed')->count(),
'cancelled' => $bookings->where('status', 'cancelled')->count(),
];
return view('user.dashboard', compact('bookings', 'stats'));
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (!auth()->check()) {
return redirect()->route('login');
}
if (auth()->user()->role !== 'admin') {
abort(403, 'الوصول للإدارة محظور');
}
return $next($request);
}
}
?>

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureOperator
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (!auth()->check() || !in_array(auth()->user()->role, ['operator', 'admin'])) {
abort(403, 'الوصول للمشغل محظور');
}
return $next($request);
}
}
?>

View File

@ -0,0 +1,61 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use App\Models\ParkingLot;
class StoreBookingRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to this request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'parking_lot_id' => 'required|exists:parking_lots,id',
'customer_name' => 'required|string|max:255',
'phone' => 'required|string|max:20|regex:/^09[0-9]{8}$/',
'start_time' => 'required|date|after:now',
'end_time' => 'required|date|after:start_time',
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'phone.regex' => 'رقم الهاتف يجب أن يكون رقم سوري صالح (09xxxxxxxx).',
];
}
/**
* Configure the validator after the validation rules have run.
*/
protected function passedValidation(): void
{
$lot = ParkingLot::findOrFail($this->parking_lot_id);
$activeCount = $lot->carRegistries()->active()->count();
if ($lot->total_capacity <= $activeCount) {
throw \Illuminate\Validation\ValidationException::withMessages([
'parking_lot_id' => ['الموقف ممتلئ حالياً. يرجى اختيار موقف آخر أو المحاولة لاحقاً.'],
])->status(409);
}
}
}
?>

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreOperatorCheckInRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Enforce via operator middleware
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'parking_lot_id' => 'required|exists:parking_lots,id',
'vehicle_plate' => 'required|string|max:50',
'user_name' => 'nullable|string|max:255',
'user_phone' => 'nullable|string|max:20',
'duration_hours' => 'required|numeric|min:0.25|max:24',
];
}
public function messages(): array
{
return [
'vehicle_plate.required' => 'رقم اللوحة مطلوب.',
'parking_lot_id.exists' => 'موقف السيارات غير موجود.',
'duration_hours.required' => 'مدة الإقامة مطلوبة.',
];
}
}
?>

View File

@ -0,0 +1,49 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreParkingLotRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Enforce via middleware
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'address' => 'required|string|max:500',
'total_capacity' => 'required|integer|min:1|max:10000',
'price_per_hour' => 'required|numeric|min:0.01|max:1000',
'latitude' => 'required|numeric|between:-90,90',
'longitude' => 'required|numeric|between:-180,180',
'working_hours' => 'required|string|max:100',
'is_active' => 'boolean',
];
}
public function messages(): array
{
return [
'name.required' => 'اسم الموقف مطلوب.',
'address.required' => 'العنوان مطلوب.',
'total_capacity.required' => 'السعة مطلوبة.',
'price_per_hour.required' => 'سعر الساعة مطلوب.',
'latitude.required' => 'خط العرض مطلوب.',
'longitude.required' => 'خط الطول مطلوب.',
];
}
}
?>

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BookingResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'parking_lot_id' => $this->parking_lot_id,
'parking_lot' => $this->whenLoaded('parkingLot', fn() => [
'id' => $this->parkingLot->id,
'name' => $this->parkingLot->name,
]),
'customer_name' => $this->customer_name,
'phone' => $this->phone,
'start_time' => $this->start_time->toIso8601String(),
'end_time' => $this->end_time->toIso8601String(),
'status' => $this->status,
];
}
}
?>

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CarRegistryResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'plate_number' => $this->plate_number,
'entry_time' => $this->entry_time,
'exit_time' => $this->exit_time,
'is_active' => $this->active,
'duration_hours' => $this->entry_time ? $this->entry_time->diffInHours(now()) : null,
'parking_lot' => new ParkingLotResource($this->whenLoaded('parkingLot')),
];
}
}
?>

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ParkingLotResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'address' => $this->address,
'total_capacity' => $this->total_capacity,
'occupied_spaces' => $this->occupied_spaces,
'available_spaces' => $this->available_spaces,
'usage_percentage' => $this->usage_percentage,
'active_registries_count' => $this->active_registries_count ?? 0,
'active_bookings_count' => $this->active_bookings_count ?? 0,
'price_per_hour' => $this->price_per_hour,
'working_hours' => $this->working_hours,
'lat' => $this->latitude,
'lng' => $this->longitude,
'status' => $this->getAvailabilityStatus(),
];
}
private function getAvailabilityStatus()
{
$available = $this->available_spaces;
$total = $this->total_capacity;
if ($available === 0) return 'full';
if ($available < $total * 0.2) return 'limited';
return 'available';
}
}
?>

48
app/Models/Booking.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\User;
class Booking extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'parking_lot_id',
'customer_name',
'phone',
'vehicle_plate',
'source',
'start_time',
'end_time',
'status',
'total_fee',
'payment_method',
'payment_proof',
'paid_at',
];
protected $casts = [
'start_time' => 'datetime',
'end_time' => 'datetime',
'paid_at' => 'datetime',
'total_fee' => 'decimal:2',
];
public function parkingLot(): BelongsTo
{
return $this->belongsTo(ParkingLot::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
?>

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Carbon\Carbon;
class CarRegistry extends Model
{
use HasFactory;
protected $fillable = [
'parking_lot_id',
'plate_number',
'entry_time',
'exit_time',
];
protected $casts = [
'entry_time' => 'datetime',
'exit_time' => 'datetime',
];
public function parkingLot(): BelongsTo
{
return $this->belongsTo(ParkingLot::class);
}
public function scopeActive($query)
{
return $query->whereNull('exit_time')
->orWhere('exit_time', '>', Carbon::now());
}
}
?>

131
app/Models/ParkingLot.php Normal file
View File

@ -0,0 +1,131 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Carbon\Carbon;
class ParkingLot extends Model
{
use HasFactory;
protected $fillable = [
'name',
'address',
'total_capacity',
'price_per_hour',
'pricing_rules',
'latitude',
'longitude',
'working_hours',
'is_active',
];
protected $casts = [
'latitude' => 'decimal:8',
'longitude' => 'decimal:8',
'price_per_hour' => 'decimal:2',
'total_capacity' => 'integer',
'is_active' => 'boolean',
'pricing_rules' => 'array',
];
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeWithStatus($query)
{
return $query->withCount([
'carRegistries as active_registries_count' => function ($q) {
$q->active();
},
'bookings as active_bookings_count' => function ($q) {
$q->where('status', 'active');
}
]);
}
public function bookings(): HasMany
{
return $this->hasMany(Booking::class);
}
public function carRegistries(): HasMany
{
return $this->hasMany(CarRegistry::class);
}
public function scopeSearch($query, $search)
{
return $query->where('name', 'like', "%{$search}%")
->orWhere('address', 'like', "%{$search}%");
}
public function getAvailableSpacesAttribute()
{
$activeBookings = $this->bookings()->where('status', 'active')->count();
$activeRegistries = $this->carRegistries()->active()->count();
$occupied = $activeBookings + $activeRegistries;
return max(0, $this->total_capacity - $occupied);
}
public function getOccupiedSpacesAttribute()
{
$activeBookings = $this->bookings()->where('status', 'active')->count();
$activeRegistries = $this->carRegistries()->active()->count();
return $activeBookings + $activeRegistries;
}
/**
* Calculate fee and per-day breakdown between two timestamps.
* Uses pricing_rules (ISO weekday keys 17) if set, otherwise price_per_hour.
*
* Returns ['total' => float, 'details' => [['day','date','hours','rate','subtotal'], ...]]
*/
public function calculateFee(Carbon $start, Carbon $end): array
{
$rules = $this->pricing_rules ?? [];
$details = [];
$total = 0.0;
$cursor = $start->copy()->seconds(0);
while ($cursor < $end) {
$dayEnd = $cursor->copy()->endOfDay()->addSecond();
$segEnd = ($dayEnd < $end) ? $dayEnd : $end;
$dow = (int) $cursor->format('N'); // 1=Mon … 7=Sun
$rate = isset($rules[$dow]) ? (float) $rules[$dow] : (float) $this->price_per_hour;
$hours = round($cursor->diffInMinutes($segEnd) / 60, 4);
$subtotal = $hours * $rate;
$details[] = [
'day' => $this->arabicDayName($dow),
'date' => $cursor->format('Y/m/d'),
'hours' => round($hours, 2),
'rate' => $rate,
'subtotal' => round($subtotal, 2),
];
$total += $subtotal;
$cursor = $segEnd;
}
return ['total' => (float) number_format(ceil($total), 2, '.', ''), 'details' => $details];
}
private function arabicDayName(int $iso): string
{
return ['الاثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت', 'الأحد'][$iso - 1] ?? '';
}
public function getUsagePercentageAttribute()
{
$occupied = $this->occupied_spaces;
return $this->total_capacity > 0 ? round(($occupied / $this->total_capacity) * 100, 2) : 0;
}
}
?>

56
app/Models/User.php Normal file
View File

@ -0,0 +1,56 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'role',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'role' => 'string',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

18
artisan Normal file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

27
bootstrap/app.php Normal file
View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->api(prepend: [
\Illuminate\Http\Middleware\TrustProxies::class,
]);
$middleware->alias([
'admin' => \App\Http\Middleware\EnsureAdmin::class,
'operator' => \App\Http\Middleware\EnsureOperator::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
?>

2
bootstrap/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

7
bootstrap/providers.php Normal file
View File

@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];

87
composer.json Normal file
View File

@ -0,0 +1,87 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.50"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8462
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View File

@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

117
config/auth.php Normal file
View File

@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

117
config/cache.php Normal file
View File

@ -0,0 +1,117 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];

35
config/cors.php Normal file
View File

@ -0,0 +1,35 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['*'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];

184
config/database.php Normal file
View File

@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

80
config/filesystems.php Normal file
View File

@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

132
config/logging.php Normal file
View File

@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View File

@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];

129
config/queue.php Normal file
View File

@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

84
config/sanctum.php Normal file
View File

@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

38
config/services.php Normal file
View File

@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

217
config/session.php Normal file
View File

@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

1
database/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.sqlite*

View File

@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@ -0,0 +1,49 @@
<?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::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@ -0,0 +1,35 @@
<?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::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@ -0,0 +1,57 @@
<?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::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@ -0,0 +1,36 @@
<?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::create('parking_lots', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('address');
$table->integer('total_capacity');
$table->decimal('price_per_hour', 8, 0);
$table->decimal('latitude', 10, 8);
$table->decimal('longitude', 11, 8);
$table->string('working_hours')->default('24/7');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('parking_lots');
}
};
?>

View File

@ -0,0 +1,35 @@
<?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::create('bookings', function (Blueprint $table) {
$table->id();
$table->foreignId('parking_lot_id')->constrained()->onDelete('cascade');
$table->string('customer_name');
$table->string('phone');
$table->dateTime('start_time');
$table->dateTime('end_time');
$table->enum('status', ['active', 'completed', 'cancelled'])->default('active');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bookings');
}
};
?>

View File

@ -0,0 +1,35 @@
<?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::create('car_registries', function (Blueprint $table) {
$table->id();
$table->foreignId('parking_lot_id')->constrained()->onDelete('cascade');
$table->string('plate_number');
$table->dateTime('entry_time');
$table->dateTime('exit_time')->nullable();
$table->timestamps();
$table->index(['parking_lot_id', 'exit_time']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('car_registries');
}
};
?>

View File

@ -0,0 +1,33 @@
<?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::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@ -0,0 +1,29 @@
<?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('parking_lots', function (Blueprint $table) {
$table->boolean('is_active')->default(true)->after('working_hours');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('parking_lots', function (Blueprint $table) {
$table->dropColumn('is_active');
});
}
};

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->enum('role', ['user', 'admin', 'operator'])->default('user')->after('email');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};

View File

@ -0,0 +1,25 @@
<?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) {
$table->foreignId('user_id')->nullable()->after('id')->constrained('users')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropForeignIdFor(\App\Models\User::class);
});
}
};

View File

@ -0,0 +1,32 @@
<?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) {
$table->string('vehicle_plate')->nullable()->after('phone');
$table->enum('source', ['walk_in', 'reservation'])->default('reservation')->after('vehicle_plate');
$table->decimal('total_fee', 10, 2)->nullable()->after('status');
$table->enum('payment_method', ['cash', 'upload'])->nullable()->after('total_fee');
$table->string('payment_proof')->nullable()->after('payment_method');
$table->timestamp('paid_at')->nullable()->after('payment_proof');
$table->string('customer_name')->nullable()->change();
$table->string('phone')->nullable()->change();
});
}
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn(['vehicle_plate', 'source', 'total_fee', 'payment_method', 'payment_proof', 'paid_at']);
});
}
};

View File

@ -0,0 +1,26 @@
<?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('parking_lots', function (Blueprint $table) {
// JSON: {"1":100,"2":100,...,"7":150} keys = ISO weekday (1=Mon,7=Sun), values = price/hour
$table->json('pricing_rules')->nullable()->after('price_per_hour');
});
}
public function down(): void
{
Schema::table('parking_lots', function (Blueprint $table) {
$table->dropColumn('pricing_rules');
});
}
};

View File

@ -0,0 +1,38 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class AdminUserSeeder extends Seeder
{
public function run(): void
{
// Super Admin
User::updateOrCreate(
['email' => 'admin@damascusparking.com'],
[
'name' => 'مدير النظام',
'role' => 'admin',
'password' => Hash::make('admin123'),
]
);
// Operator
User::updateOrCreate(
['email' => 'operator@damascusparking.com'],
[
'name' => 'مشغل الموقف',
'role' => 'operator',
'password' => Hash::make('operator123'),
]
);
echo "✅ Super Admin: admin@damascusparking.com / admin123\n";
echo "✅ Operator: operator@damascusparking.com / operator123\n";
}
}
?>

View File

@ -0,0 +1,67 @@
<?php
namespace Database\Seeders;
use App\Models\Booking;
use App\Models\ParkingLot;
use Illuminate\Database\Seeder;
use Carbon\Carbon;
class BookingSeeder extends Seeder
{
public function run(): void
{
$lots = ParkingLot::all();
if ($lots->isEmpty()) {
return;
}
$names = ['أحمد الخطيب', 'سامر العلي', 'رنا موسى', 'خالد إبراهيم', 'منى حسن',
'عمر الزعبي', 'لينا صالح', 'فراس نعمة', 'هبة الدار', 'باسل قاسم',
'نور السيد', 'وسيم طه', 'ديانا فارس', 'زياد جبر', 'ريم عبدالله'];
$phones = ['0912345678', '0923456789', '0934567890', '0945678901', '0956789012',
'0967890123', '0978901234', '0989012345', '0990123456', '0901234567'];
$now = Carbon::now();
// Seed bookings spread across the last 7 days
foreach ($lots as $lot) {
// 25 active bookings per lot
$activeCount = rand(2, 5);
for ($i = 0; $i < $activeCount; $i++) {
$start = $now->clone()->subHours(rand(1, 5));
$end = $start->clone()->addHours(rand(1, 4));
Booking::create([
'parking_lot_id' => $lot->id,
'customer_name' => $names[array_rand($names)],
'phone' => $phones[array_rand($phones)],
'start_time' => $start,
'end_time' => $end,
'status' => 'active',
'created_at' => $start,
'updated_at' => $start,
]);
}
// 38 completed bookings spread over the past 7 days
$completedCount = rand(3, 8);
for ($i = 0; $i < $completedCount; $i++) {
$daysAgo = rand(0, 6);
$start = $now->clone()->subDays($daysAgo)->subHours(rand(2, 10));
$end = $start->clone()->addHours(rand(1, 3));
Booking::create([
'parking_lot_id' => $lot->id,
'customer_name' => $names[array_rand($names)],
'phone' => $phones[array_rand($phones)],
'start_time' => $start,
'end_time' => $end,
'status' => 'completed',
'created_at' => $start,
'updated_at' => $end,
]);
}
}
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
$this->call(ParkingLotSeeder::class);
$this->call(BookingSeeder::class);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\ParkingLot;
use App\Models\CarRegistry;
class ParkingLotSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$lots = [
[
'name' => 'موقف البرامكة',
'address' => 'شارع البرامكة، دمشق',
'total_capacity' => 120,
'price_per_hour' => 1500,
'latitude' => 33.5138,
'longitude' => 36.2765,
'working_hours' => '06:00 - 22:00',
],
[
'name' => 'موقف المرجة',
'address' => 'ساحة المرجة، دمشق القديمة',
'total_capacity' => 80,
'price_per_hour' => 2000,
'working_hours' => '24/7',
'latitude' => 33.5105,
'longitude' => 36.3118,
],
[
'name' => 'موقف المالكي',
'address' => 'شارع المالكي، دمشق',
'total_capacity' => 60,
'price_per_hour' => 1200,
'working_hours' => '24/7',
'latitude' => 33.5230,
'longitude' => 36.3050,
],
[
'name' => 'موقف المزة',
'address' => 'شارع المزة الأوتوستراد',
'total_capacity' => 100,
'price_per_hour' => 1800,
'working_hours' => '05:00 - 23:00',
'latitude' => 33.5080,
'longitude' => 36.2650,
],
[
'name' => 'موقف كفرسوسة',
'address' => 'طريق المزة، كفرسوسة',
'total_capacity' => 75,
'price_per_hour' => 1000,
'working_hours' => '24/7',
'latitude' => 33.4980,
'longitude' => 36.2450,
],
];
foreach ($lots as $lotData) {
$lot = ParkingLot::create($lotData);
// Seed some active car registries (walk-ins + bookings) for realistic availability
$occupied = rand(10, (int)($lotData['total_capacity'] * 0.4)); // 10-40% occupied
for ($i = 0; $i < $occupied; $i++) {
CarRegistry::create([
'parking_lot_id' => $lot->id,
'plate_number' => sprintf('%d س دم %s', rand(100, 999), substr(md5(rand()), 0, 3)),
'entry_time' => now()->subHours(rand(1, 12)),
'exit_time' => rand(0, 4) > 3 ? null : now()->addHours(rand(1, 6)), // Most still active
]);
}
}
}
}
?>

2447
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@popperjs/core": "^2.11.8",
"axios": "^1.11.0",
"bootstrap": "^5.3.8",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0",
"sass": "^1.99.0",
"vite": "^7.0.7"
}
}

36
phpunit.xml Normal file
View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

25
public/.htaccess Normal file
View File

@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

0
public/favicon.ico Normal file
View File

20
public/index.php Normal file
View File

@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow:

11
resources/css/app.css Normal file
View File

@ -0,0 +1,11 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

799
resources/css/app.scss Normal file
View File

@ -0,0 +1,799 @@
// Bootstrap Variable Overrides
$font-family-base: 'Cairo', sans-serif;
$font-size-base: 0.9375rem; // 15px
$line-height-base: 1.7; // Arabic needs breathing room
$border-radius: 0.5rem;
$border-radius-sm: 0.375rem;
$border-radius-lg: 0.75rem;
$border-radius-xl: 1rem;
$border-radius-xxl: 1.5rem;
$card-border-width: 0;
$card-border-radius: $border-radius-lg;
$card-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.07), 0 1px 2px rgba(0, 0, 0, 0.04);
$input-border-radius: $border-radius;
$input-border-radius-lg: $border-radius;
$input-padding-y: 0.55rem;
$input-padding-x: 0.875rem;
$btn-border-radius: $border-radius;
$btn-border-radius-lg: $border-radius;
$btn-padding-y-lg: 0.6rem;
$btn-padding-x-lg: 1.5rem;
$table-cell-padding-y: 0.875rem;
$table-cell-padding-x: 1rem;
$table-border-color: #f1f5f9;
$modal-border-radius: $border-radius-xl;
$modal-content-border-width: 0;
$modal-content-box-shadow-xs: 0 1rem 3rem rgba(0,0,0,.175);
$badge-border-radius: 50rem;
$badge-padding-y: .35em;
$badge-padding-x: .75em;
$badge-font-size: .8em;
$badge-font-weight: 600;
$enable-negative-margins: true;
@import "bootstrap/scss/bootstrap";
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
// CSS Variables
:root {
--sidebar-width: 260px;
--sidebar-bg: #0f172a;
--sidebar-border: rgba(255,255,255,.06);
--sidebar-text: #94a3b8;
--sidebar-text-hover: #f1f5f9;
--sidebar-hover-bg: rgba(255,255,255,.07);
--sidebar-active-bg: rgba(99,102,241,.25);
--sidebar-active-text: #a5b4fc;
--sidebar-active-border:#6366f1;
--topbar-height: 62px;
--topbar-bg: #ffffff;
--app-bg: #f1f5f9;
--mobile-nav-h: 56px;
--card-bg: #ffffff;
--text-primary: #0f172a;
--text-muted: #64748b;
--border-color: #e2e8f0;
}
// Base
body {
font-family: 'Cairo', sans-serif;
color: var(--text-primary);
background-color: var(--app-bg);
}
// App Layout (Sidebar + Main)
.app-layout {
display: flex;
min-height: 100vh;
// In RTL flex-direction:row, the first child (sidebar) appears on the RIGHT
}
// Sidebar
.app-sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
overflow-y: auto;
scrollbar-width: none;
transition: transform .3s ease;
z-index: 1040;
&::-webkit-scrollbar { display: none; }
}
.sidebar-logo {
padding: 1.375rem 1.25rem;
display: flex;
align-items: center;
gap: .75rem;
border-bottom: 1px solid var(--sidebar-border);
text-decoration: none;
.logo-icon {
width: 38px;
height: 38px;
background: #6366f1;
border-radius: .5rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 1.25rem;
color: #fff;
}
.logo-text {
color: #f1f5f9;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.logo-sub {
font-size: .72rem;
color: var(--sidebar-text);
font-weight: 400;
}
}
.sidebar-section {
padding: 1.25rem 1.25rem .25rem;
font-size: .68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: rgba(255,255,255,.25);
}
.sidebar-nav {
padding: .5rem 0;
flex: 1;
}
.sidebar-link {
display: flex;
align-items: center;
gap: .75rem;
padding: .625rem 1rem;
margin: .1rem .625rem;
border-radius: .5rem;
color: var(--sidebar-text);
text-decoration: none;
font-size: .9rem;
font-weight: 500;
transition: background .15s, color .15s;
border-inline-start: 2px solid transparent;
.sidebar-icon {
width: 1.125rem;
text-align: center;
font-size: 1rem;
flex-shrink: 0;
}
&:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-hover);
}
&.active {
background: var(--sidebar-active-bg);
color: var(--sidebar-active-text);
border-inline-start-color: var(--sidebar-active-border);
}
}
.sidebar-footer {
padding: 1rem 1.25rem;
border-top: 1px solid var(--sidebar-border);
margin-top: auto;
.user-name {
font-size: .85rem;
font-weight: 600;
color: #f1f5f9;
line-height: 1.2;
}
.user-role {
font-size: .72rem;
color: var(--sidebar-text);
}
.user-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(99,102,241,.4);
color: #a5b4fc;
font-weight: 700;
font-size: .9rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
}
// Main Body
.app-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
// Topbar
.app-topbar {
height: var(--topbar-height);
background: var(--topbar-bg);
border-bottom: 1px solid var(--border-color);
padding: 0 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
position: sticky;
top: 0;
z-index: 100;
.topbar-title {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.topbar-actions {
display: flex;
align-items: center;
gap: .5rem;
}
}
// Sidebar toggle button (mobile)
.sidebar-toggle {
background: none;
border: none;
padding: .375rem .5rem;
color: var(--text-muted);
border-radius: .375rem;
cursor: pointer;
display: none;
font-size: 1.25rem;
&:hover { background: #f1f5f9; color: var(--text-primary); }
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.5);
z-index: 1039;
backdrop-filter: blur(2px);
}
// App Content
.app-content {
padding: 1.5rem;
flex: 1;
}
// Mobile Responsive
@media (max-width: 991.98px) {
.app-sidebar {
position: fixed;
top: 0;
inset-inline-end: 0; // right in RTL
height: 100%;
transform: translateX(calc(-1 * var(--sidebar-width)));
// In RTL, translateX negative moves LEFT (off screen)
// We need to hide it to the right side
transform: translateX(100%);
&.is-open {
transform: translateX(0);
}
}
.sidebar-overlay.is-open {
display: block;
}
.sidebar-toggle {
display: flex;
align-items: center;
}
}
// Page Header
.page-header {
margin-bottom: 1.5rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--border-color);
.page-title {
font-size: 1.3rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 .2rem;
}
.page-subtitle {
font-size: .85rem;
color: var(--text-muted);
margin: 0;
}
}
// Cards
.card {
border: 0;
box-shadow: var(--card-box-shadow, 0 1px 4px rgba(0,0,0,.07));
}
.card-header {
background: transparent;
border-bottom: 1px solid var(--border-color);
padding: 1rem 1.25rem;
font-weight: 600;
}
.card-body-padded {
padding: 1.25rem;
}
// Stat card
.stat-card {
transition: transform .2s ease, box-shadow .2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,.1) !important;
}
}
// Hoverable card
.card-hover {
transition: transform .2s ease, box-shadow .2s ease;
cursor: pointer;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,.1) !important;
}
}
// Quick action card
.quick-card {
transition: all .2s ease;
text-decoration: none;
display: block;
border-radius: $border-radius-lg !important;
&:hover {
transform: translateY(-4px);
box-shadow: 0 10px 30px rgba(0,0,0,.1) !important;
}
.quick-icon {
width: 52px;
height: 52px;
border-radius: $border-radius;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
margin-bottom: .875rem;
}
h6 { font-weight: 700; margin-bottom: .25rem; }
small { color: var(--text-muted); }
}
// Tables
.app-table {
margin: 0;
thead th {
background: #f8fafc;
color: var(--text-muted);
font-size: .8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .03em;
border-bottom: 1px solid var(--border-color);
padding: .875rem 1rem;
white-space: nowrap;
}
tbody td {
padding: .875rem 1rem;
vertical-align: middle;
border-bottom: 1px solid #f8fafc;
color: var(--text-primary);
font-size: .9rem;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: #f8fafc; }
}
// Badges
.badge-soft-success {
background: rgba(22, 163, 74, .12);
color: #15803d;
}
.badge-soft-danger {
background: rgba(220, 53, 69, .12);
color: #b91c1c;
}
.badge-soft-warning {
background: rgba(217, 119, 6, .12);
color: #92400e;
}
.badge-soft-info {
background: rgba(6, 182, 212, .12);
color: #0e7490;
}
.badge-soft-primary {
background: rgba(99, 102, 241, .12);
color: #4338ca;
}
.badge-soft-secondary {
background: rgba(100, 116, 139, .12);
color: #475569;
}
// Forms
.form-control, .form-select {
font-family: 'Cairo', sans-serif;
border-color: var(--border-color);
color: var(--text-primary);
&:focus {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99,102,241,.15);
}
}
.form-label {
font-weight: 600;
font-size: .875rem;
color: var(--text-primary);
margin-bottom: .375rem;
}
// Auth Layout
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: linear-gradient(145deg, #eef2ff 0%, #f0fdf4 50%, #f0f9ff 100%);
position: relative;
&::before {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(circle at 20% 80%, rgba(99,102,241,.07) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(16,185,129,.06) 0%, transparent 50%);
pointer-events: none;
}
}
.auth-box {
width: 100%;
max-width: 460px;
position: relative;
z-index: 1;
}
.auth-brand {
text-align: center;
margin-bottom: 1.75rem;
.brand-icon {
width: 54px;
height: 54px;
background: #6366f1;
border-radius: .875rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: #fff;
margin-bottom: .75rem;
box-shadow: 0 4px 14px rgba(99,102,241,.4);
}
h1 {
font-size: 1.5rem;
font-weight: 800;
color: var(--text-primary);
margin: 0 0 .25rem;
}
p {
color: var(--text-muted);
font-size: .875rem;
margin: 0;
}
}
.auth-card {
background: #fff;
border-radius: $border-radius-xl;
box-shadow: 0 4px 24px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,.04);
padding: 2rem;
}
.auth-divider {
text-align: center;
position: relative;
margin: 1.25rem 0;
&::before {
content: '';
position: absolute;
top: 50%;
inset-inline-start: 0;
inset-inline-end: 0;
border-top: 1px solid var(--border-color);
}
span {
background: #fff;
padding: 0 .75rem;
color: var(--text-muted);
font-size: .8rem;
position: relative;
}
}
// Public (Index) Page
.public-header {
background: var(--sidebar-bg);
padding: 1.25rem 0;
position: sticky;
top: 0;
z-index: 200;
box-shadow: 0 2px 12px rgba(0,0,0,.15);
}
.parking-card {
transition: all .18s ease;
border: 1px solid transparent;
cursor: pointer;
&:hover {
border-color: rgba(99,102,241,.3);
background: #fafbff;
transform: translateX(2px); // RTL: hover nudges toward inline-end (left)
}
&.active-card {
border-color: #6366f1;
background: #f5f3ff;
}
}
// availability badge
.avail-full { background: rgba(239,68,68,.1); color: #dc2626; }
.avail-limited { background: rgba(245,158,11,.1); color: #b45309; }
.avail-open { background: rgba(16,185,129,.1); color: #059669; }
// Utilities
.fw-800 { font-weight: 800 !important; }
.text-xs { font-size: .75rem !important; }
.text-sm { font-size: .85rem !important; }
.lh-sm { line-height: 1.35 !important; }
.rounded-12 { border-radius: .75rem !important; }
.rounded-16 { border-radius: 1rem !important; }
.bg-indigo-soft { background: rgba(99,102,241,.1) !important; }
.text-indigo { color: #6366f1 !important; }
.border-indigo { border-color: #6366f1 !important; }
// Desktop Polish
@media (min-width: 992px) {
.app-sidebar {
box-shadow: -4px 0 28px rgba(0,0,0,.14);
}
}
@media (min-width: 768px) {
.app-topbar {
backdrop-filter: blur(8px);
background: rgba(255,255,255,.96);
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 28px rgba(0,0,0,.09) !important;
}
}
// Mobile App Shell
// Bottom Navigation Bar (YouTube-style)
.mobile-bottom-nav {
position: fixed;
bottom: 0;
inset-inline-start: 0;
inset-inline-end: 0;
height: 56px;
background: #ffffff;
border-top: 1px solid #e5e5e5;
z-index: 1050;
display: none;
align-items: stretch;
padding: 0;
margin: 0;
list-style: none;
.mob-nav-item {
flex: 1;
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
height: 100%;
color: #909090 !important;
text-decoration: none !important;
font-size: .64rem;
font-weight: 600;
font-family: 'Cairo', sans-serif;
transition: color .15s;
cursor: pointer;
border: none;
background: none !important;
padding: 0;
margin: 0;
-webkit-tap-highlight-color: transparent;
outline: none;
box-shadow: none;
i {
font-size: 1.4rem;
line-height: 1;
display: block;
}
span {
display: block;
line-height: 1;
}
&.active {
color: #0f172a !important;
}
&:hover:not(.active) { color: #474747 !important; }
&:active { opacity: .6; }
}
}
// Mobile Rules (<768px)
@media (max-width: 767px) {
/* Show bottom nav */
.mobile-bottom-nav { display: flex !important; }
/* Shared content bottom padding */
.mob-nav-pad { padding-bottom: calc(var(--mobile-nav-h) + .5rem) !important; }
/* ── Admin / Operator layout ────────────────────────────────────────── */
.sidebar-toggle { display: none !important; }
.app-layout { display: block; }
.app-body {
padding-bottom: var(--mobile-nav-h);
min-height: 100svh;
}
.app-topbar {
height: 54px;
padding: 0 1rem;
backdrop-filter: none;
background: #ffffff;
.topbar-title { font-size: .9rem; }
}
.app-content { padding: .875rem .875rem 1.5rem; }
/* Stat cards 2-per-row on mobile */
.app-content .row > .col-xl-2 {
flex: 0 0 50%;
max-width: 50%;
}
/* ── Public page ────────────────────────────────────────────────────── */
.public-header { padding: .625rem 0; }
.mob-hero-compact { padding: .875rem 0 1rem !important; }
/* Section switching — hide inactive section */
.mob-hidden { display: none !important; }
/* Remove sticky on list card */
.mob-sticky-desktop { position: static !important; }
/* List scrolls with page, no fixed height */
#parkingList { max-height: none !important; }
/* Map fills available viewport */
#map {
height: calc(100vh - 230px) !important;
height: calc(100svh - 230px) !important;
min-height: 280px;
}
}
// Desktop overrides
@media (min-width: 768px) {
.mobile-bottom-nav { display: none !important; }
}
// RTL input-group border-radius fix
// Bootstrap 5 ships LTR-only border-radius logic for .input-group.
// These overrides flip the rounded corners for dir="rtl" pages.
//
// RTL icon-spacing fix
// Bootstrap SCSS compiles me-* to physical margin-right and ms-* to
// margin-left. In an RTL layout the icon sits on the RIGHT and the text
// on the LEFT, so margin-right pushes AWAY from the text. We swap the
// physical sides so me-* / ms-* behave as Bootstrap's logical properties
// intend (margin-inline-end / margin-inline-start).
[dir="rtl"] {
.me-1 { margin-right: 0 !important; margin-left: .25rem !important; }
.me-2 { margin-right: 0 !important; margin-left: .5rem !important; }
.me-3 { margin-right: 0 !important; margin-left: 1rem !important; }
.me-4 { margin-right: 0 !important; margin-left: 1.5rem !important; }
.me-5 { margin-right: 0 !important; margin-left: 3rem !important; }
.ms-1 { margin-left: 0 !important; margin-right: .25rem !important; }
.ms-2 { margin-left: 0 !important; margin-right: .5rem !important; }
.ms-3 { margin-left: 0 !important; margin-right: 1rem !important; }
.ms-4 { margin-left: 0 !important; margin-right: 1.5rem !important; }
.ms-5 { margin-left: 0 !important; margin-right: 3rem !important; }
.ms-auto { margin-left: 0 !important; margin-right: auto !important; }
.me-auto { margin-right: 0 !important; margin-left: auto !important; }
// First child (icon / prepend) sits visually on the RIGHT in RTL
.input-group > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) {
border-top-right-radius: var(--bs-border-radius) !important;
border-bottom-right-radius: var(--bs-border-radius) !important;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
// Last child (input / append) sits visually on the LEFT in RTL
.input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) {
margin-right: -1px;
margin-left: 0;
border-top-left-radius: var(--bs-border-radius) !important;
border-bottom-left-radius: var(--bs-border-radius) !important;
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
// Same for -sm variant (used in admin search bars)
.input-group-sm > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),
.input-group > .form-control-sm:not(:last-child) {
border-top-right-radius: var(--bs-border-radius-sm) !important;
border-bottom-right-radius: var(--bs-border-radius-sm) !important;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.input-group-sm > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback),
.input-group > .btn-sm:not(:first-child) {
margin-right: -1px;
margin-left: 0;
border-top-left-radius: var(--bs-border-radius-sm) !important;
border-bottom-left-radius: var(--bs-border-radius-sm) !important;
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
}

1
resources/js/app.js Normal file
View File

@ -0,0 +1 @@
import './bootstrap';

6
resources/js/bootstrap.js vendored Normal file
View File

@ -0,0 +1,6 @@
import 'bootstrap';
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View File

@ -0,0 +1,177 @@
@extends('layouts.admin')
@section('title', 'الحجوزات النشطة — دمشق باركينغ')
@section('page-title', 'الحجوزات النشطة')
@section('content')
{{-- ── Header ──────────────────────────────────────────────────────────────── --}}
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
<div>
<h2 class="fw-800 mb-1" style="font-size:1.15rem;color:#0f172a;">الحجوزات النشطة</h2>
<p class="text-sm mb-0" style="color:#64748b;">
السيارات المسجّلة حالياً · يتجدد كل 30 ثانية
</p>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge badge-soft-success fw-600" style="font-size:.82rem;padding:.4em .9em;">
<i class="bi bi-circle-fill me-1" style="font-size:.45rem;vertical-align:middle;"></i>
{{ $activeBookings->total() }} نشط
</span>
<span class="badge badge-soft-secondary text-xs" id="refresh-badge">تحديث بعد 30ث</span>
</div>
</div>
{{-- ── Filter ────────────────────────────────────────────────────────────────── --}}
<div class="card mb-4">
<div class="card-body p-3">
<div class="row align-items-end g-3">
<div class="col-md-5">
<label class="form-label">فلترة حسب الموقف</label>
<select id="lotFilter" class="form-select form-select-sm">
<option value="">جميع المواقف</option>
@foreach($parkingLots as $lot)
<option value="{{ $lot->id }}" {{ request('parking_lot_id') == $lot->id ? 'selected' : '' }}>
{{ $lot->name }}
</option>
@endforeach
</select>
</div>
@if(request('parking_lot_id'))
<div class="col-auto">
<a href="{{ route('admin.bookings.active') }}"
class="btn btn-sm fw-600"
style="background:#f1f5f9;color:#475569;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;">
<i class="bi bi-x me-1"></i>إلغاء الفلتر
</a>
</div>
@endif
</div>
</div>
</div>
{{-- ── Table ────────────────────────────────────────────────────────────────── --}}
<div class="card">
<div class="table-responsive">
<table class="app-table w-100">
<thead>
<tr>
<th>رقم اللوحة</th>
<th>السائق</th>
<th>الهاتف</th>
<th>الموقف</th>
<th>وقت الدخول</th>
<th>وقت الخروج</th>
<th>المدة</th>
<th class="text-center">إنهاء</th>
</tr>
</thead>
<tbody>
@forelse($activeBookings as $booking)
<tr id="row-{{ $booking->id }}">
<td>
<span class="fw-700" style="font-family:monospace;font-size:.95rem;color:#0f172a;">
{{ $booking->vehicle_plate ?? $booking->customer_name ?? '--' }}
</span>
</td>
<td class="text-sm">{{ $booking->user_name ?? $booking->customer_name ?? '--' }}</td>
<td class="text-sm" style="direction:ltr;text-align:right;">
{{ $booking->user_phone ?? $booking->phone ?? '--' }}
</td>
<td>
<span class="badge badge-soft-info text-xs fw-600">
{{ $booking->parkingLot->name }}
</span>
</td>
<td class="text-xs" style="color:#64748b;">{{ $booking->start_time->format('Y/m/d H:i') }}</td>
<td class="text-xs" style="color:#64748b;">{{ $booking->end_time->format('Y/m/d H:i') }}</td>
<td>
<span class="badge badge-soft-warning text-xs fw-600">
{{ $booking->start_time->diffForHumans(now(), true) }}
</span>
</td>
<td class="text-center">
<button class="btn btn-sm fw-600"
style="background:rgba(239,68,68,.1);color:#dc2626;border:none;border-radius:.375rem;font-family:'Cairo',sans-serif;padding:.3rem .75rem;"
onclick="completeBooking({{ $booking->id }}, this)">
<i class="bi bi-stop-circle me-1"></i>إنهاء
</button>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="text-center py-5">
<i class="bi bi-check-circle d-block mb-3" style="font-size:2.5rem;color:#10b981;opacity:.5;"></i>
<p class="fw-600 mb-0" style="color:#475569;">لا توجد حجوزات نشطة</p>
<p class="text-sm mb-0" style="color:#94a3b8;">جميع المواقف خالية حالياً</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($activeBookings->hasPages())
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
<span class="text-xs" style="color:#64748b;">
عرض {{ $activeBookings->firstItem() }}{{ $activeBookings->lastItem() }}
من {{ $activeBookings->total() }}
</span>
{{ $activeBookings->appends(request()->query())->links('pagination::bootstrap-5') }}
</div>
@endif
</div>
@push('scripts')
<script>
document.getElementById('lotFilter').addEventListener('change', function () {
const url = new URL(window.location);
this.value ? url.searchParams.set('parking_lot_id', this.value)
: url.searchParams.delete('parking_lot_id');
window.location.href = url.toString();
});
async function completeBooking(id, btn) {
if (!confirm('إنهاء هذا الحجز؟')) return;
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const res = await fetch(`/admin/bookings/${id}/complete`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json'
}
});
const data = await res.json();
if (data.success) {
const row = document.getElementById('row-' + id);
row.style.transition = 'opacity .4s';
row.style.opacity = '0';
setTimeout(() => row.remove(), 400);
} else {
alert(data.message || 'خطأ');
btn.innerHTML = orig;
btn.disabled = false;
}
} catch {
alert('خطأ في الاتصال');
btn.innerHTML = orig;
btn.disabled = false;
}
}
// Countdown
let t = 30;
const badge = document.getElementById('refresh-badge');
setInterval(() => {
t--;
badge.textContent = `تحديث بعد ${t}ث`;
if (t <= 0) location.reload();
}, 1000);
</script>
@endpush
@endsection

View File

@ -0,0 +1,110 @@
@extends('layouts.admin')
@section('title', 'جميع الحجوزات — دمشق باركينغ')
@section('page-title', 'جميع الحجوزات')
@section('content')
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
<div>
<h2 class="fw-800 mb-1" style="font-size:1.15rem;color:#0f172a;">جميع الحجوزات</h2>
<p class="text-sm mb-0" style="color:#64748b;">سجل كامل بجميع الحجوزات في النظام</p>
</div>
<div class="d-flex gap-2">
<a href="{{ route('admin.bookings.active') }}"
class="btn btn-sm fw-600"
style="background:rgba(16,185,129,.1);color:#059669;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;">
<i class="bi bi-activity me-1"></i>النشطة فقط
</a>
<button class="btn btn-sm fw-600" onclick="exportCSV()"
style="background:rgba(16,185,129,.1);color:#059669;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;">
<i class="bi bi-download me-1"></i>CSV
</button>
</div>
</div>
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-700 text-sm">قائمة الحجوزات</span>
<div class="input-group" style="max-width:240px;">
<input type="text" id="searchInput" class="form-control form-control-sm"
style="border-color:#e2e8f0;border-inline-end:none;" placeholder="بحث...">
<button class="btn btn-sm" style="background:#6366f1;color:#fff;border:none;" onclick="doSearch()">
<i class="bi bi-search"></i>
</button>
</div>
</div>
<div class="table-responsive">
<table class="app-table w-100">
<thead>
<tr>
<th>#</th>
<th>الموقف</th>
<th>العميل</th>
<th>اللوحة</th>
<th>الهاتف</th>
<th>البداية</th>
<th>النهاية</th>
<th class="text-center">الحالة</th>
</tr>
</thead>
<tbody>
@forelse($bookings as $booking)
<tr>
<td class="text-xs" style="color:#94a3b8;">{{ $booking->id }}</td>
<td><span class="fw-600">{{ $booking->parkingLot->name ?? '--' }}</span></td>
<td class="text-sm">{{ $booking->customer_name ?? $booking->user_name ?? '--' }}</td>
<td>
<span style="font-family:monospace;font-weight:700;color:#0f172a;">
{{ $booking->vehicle_plate ?? '--' }}
</span>
</td>
<td class="text-sm" style="direction:ltr;text-align:right;">
{{ $booking->phone ?? $booking->user_phone ?? '--' }}
</td>
<td class="text-xs" style="color:#64748b;">{{ $booking->start_time->format('Y/m/d H:i') }}</td>
<td class="text-xs" style="color:#64748b;">{{ $booking->end_time->format('Y/m/d H:i') }}</td>
<td class="text-center">
@if($booking->status === 'active')
<span class="badge badge-soft-success text-xs">نشط</span>
@elseif($booking->status === 'completed')
<span class="badge badge-soft-secondary text-xs">مكتمل</span>
@else
<span class="badge badge-soft-warning text-xs">{{ $booking->status }}</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="8" class="text-center py-5">
<i class="bi bi-calendar-x d-block mb-2" style="font-size:2rem;color:#cbd5e1;"></i>
<span class="text-sm" style="color:#94a3b8;">لا توجد حجوزات</span>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if(isset($bookings) && $bookings->hasPages())
<div class="card-header">
{{ $bookings->links('pagination::bootstrap-5') }}
</div>
@endif
</div>
@push('scripts')
<script>
function exportCSV() {
window.location.href = '{{ route("admin.bookings.export", ["format" => "csv"]) }}';
}
function doSearch() {
window.location.href = `/admin/bookings?search=${encodeURIComponent(document.getElementById('searchInput').value)}`;
}
document.getElementById('searchInput').addEventListener('keypress', e => {
if (e.key === 'Enter') doSearch();
});
</script>
@endpush
@endsection

View File

@ -0,0 +1,265 @@
@extends('layouts.admin')
@section('title', 'لوحة التحكم — دمشق باركينغ')
@section('page-title', 'لوحة التحكم')
@section('content')
{{-- ── Stats Row ──────────────────────────────────────────────────────────── --}}
<div class="row g-3 mb-4">
@php
$stats = [
['id' => 'total-parking-lots', 'label' => 'إجمالي المواقف', 'icon' => 'bi-buildings', 'color' => '#6366f1', 'bg' => 'rgba(99,102,241,.1)'],
['id' => 'total-bookings', 'label' => 'إجمالي الحجوزات', 'icon' => 'bi-calendar3', 'color' => '#0ea5e9', 'bg' => 'rgba(14,165,233,.1)'],
['id' => 'active-bookings', 'label' => 'الحجوزات النشطة', 'icon' => 'bi-clock-history', 'color' => '#f59e0b', 'bg' => 'rgba(245,158,11,.1)'],
['id' => 'occupancy-rate', 'label' => 'معدل الإشغال', 'icon' => 'bi-graph-up-arrow', 'color' => '#ef4444', 'bg' => 'rgba(239,68,68,.1)'],
['id' => 'estimated-revenue', 'label' => 'الإيرادات المتوقعة', 'icon' => 'bi-cash-coin', 'color' => '#10b981', 'bg' => 'rgba(16,185,129,.1)'],
['id' => 'available-spots', 'label' => 'الأماكن المتاحة', 'icon' => 'bi-check2-square', 'color' => '#8b5cf6', 'bg' => 'rgba(139,92,246,.1)'],
];
@endphp
@foreach($stats as $s)
<div class="col-xl-2 col-lg-4 col-sm-6">
<div class="card stat-card h-100">
<div class="card-body p-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<div class="rounded-3 d-flex align-items-center justify-content-center"
style="width:42px;height:42px;background:{{ $s['bg'] }};">
<i class="bi {{ $s['icon'] }}" style="font-size:1.2rem;color:{{ $s['color'] }};"></i>
</div>
</div>
<div class="fw-800 lh-sm mb-1" id="{{ $s['id'] }}"
style="font-size:1.6rem;color:{{ $s['color'] }};">--</div>
<div class="text-xs" style="color:#64748b;">{{ $s['label'] }}</div>
</div>
</div>
</div>
@endforeach
</div>
{{-- ── Quick Actions ───────────────────────────────────────────────────────── --}}
<div class="row g-3 mb-4">
<div class="col-12">
<h5 class="fw-700 mb-3" style="color:#0f172a;font-size:.95rem;">
<i class="bi bi-lightning-charge-fill me-2" style="color:#f59e0b;"></i>
إجراءات سريعة
</h5>
<div class="row g-3">
<div class="col-lg-3 col-sm-6">
<a href="{{ route('admin.parking-lots.index') }}" class="quick-card card h-100 p-4">
<div class="quick-icon" style="background:rgba(99,102,241,.1);">
<i class="bi bi-buildings" style="color:#6366f1;"></i>
</div>
<h6 style="color:#0f172a;">إدارة المواقف</h6>
<small>إضافة · تعديل · تفعيل</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<a href="{{ route('admin.bookings.active') }}" class="quick-card card h-100 p-4">
<div class="quick-icon" style="background:rgba(16,185,129,.1);">
<i class="bi bi-calendar-check" style="color:#10b981;"></i>
</div>
<h6 style="color:#0f172a;">الحجوزات النشطة</h6>
<small>إنهاء · فلترة · متابعة</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<a href="{{ route('operator.dashboard') }}" class="quick-card card h-100 p-4">
<div class="quick-icon" style="background:rgba(245,158,11,.1);">
<i class="bi bi-person-badge" style="color:#f59e0b;"></i>
</div>
<h6 style="color:#0f172a;">لوحة المشغّل</h6>
<small>دخول وخروج فوري</small>
</a>
</div>
<div class="col-lg-3 col-sm-6">
<div class="quick-card card h-100 p-4" onclick="alert('قريباً...')">
<div class="quick-icon" style="background:rgba(100,116,139,.1);">
<i class="bi bi-gear" style="color:#64748b;"></i>
</div>
<h6 style="color:#0f172a;">الإعدادات</h6>
<small>تخصيص · تقارير</small>
</div>
</div>
</div>
</div>
</div>
{{-- ── Charts ──────────────────────────────────────────────────────────────── --}}
<div class="row g-3 mb-4">
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-700" style="font-size:.9rem;">
<i class="bi bi-bar-chart-line me-2" style="color:#6366f1;"></i>
الحجوزات اليومية آخر 7 أيام
</span>
</div>
<div class="card-body p-3">
<canvas id="dailyChart" height="140"></canvas>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-700" style="font-size:.9rem;">
<i class="bi bi-trophy me-2" style="color:#f59e0b;"></i>
أفضل 5 مواقف
</span>
</div>
<div class="card-body p-3 d-flex align-items-center justify-content-center">
<canvas id="topChart" height="140"></canvas>
</div>
</div>
</div>
</div>
{{-- ── Recent Bookings ─────────────────────────────────────────────────────── --}}
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-700" style="font-size:.9rem;">
<i class="bi bi-clock-history me-2" style="color:#0ea5e9;"></i>
آخر الحجوزات
</span>
<span class="badge badge-soft-secondary" id="last-updated">--</span>
</div>
<div class="table-responsive">
<table class="app-table w-100">
<thead>
<tr>
<th>الموقف</th>
<th>العميل</th>
<th>الهاتف</th>
<th>الحالة</th>
<th>وقت البدء</th>
<th>وقت الانتهاء</th>
</tr>
</thead>
<tbody id="bookings-body">
<tr>
<td colspan="6" class="text-center py-4" style="color:#94a3b8;">
<div class="spinner-border spinner-border-sm me-2"></div>
جاري التحميل...
</td>
</tr>
</tbody>
</table>
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script>
let dailyChart, topChart;
async function loadStats() {
try {
const { success, data } = await fetch('/admin/stats').then(r => r.json());
if (!success) return;
document.getElementById('total-parking-lots').textContent = data.total_parking_lots ?? 0;
document.getElementById('total-bookings').textContent = data.total_bookings ?? 0;
document.getElementById('active-bookings').textContent = data.active_bookings ?? 0;
document.getElementById('occupancy-rate').textContent = (data.occupancy_rate ?? 0) + '%';
document.getElementById('estimated-revenue').textContent = (data.estimated_revenue ?? 0).toLocaleString('ar-SA') + ' ر.س';
document.getElementById('available-spots').textContent = data.available_spots ?? 0;
} catch {}
}
async function loadCharts() {
try {
const { success, data } = await fetch('/admin/charts').then(r => r.json());
if (!success) return;
const labels = Array.from({length: 7}, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - (6 - i));
return d.toLocaleDateString('ar-SA', {weekday: 'short'});
});
if (dailyChart) dailyChart.destroy();
dailyChart = new Chart(document.getElementById('dailyChart'), {
type: 'bar',
data: {
labels,
datasets: [{
label: 'حجوزات',
data: data.daily_bookings ?? [],
backgroundColor: 'rgba(99,102,241,.2)',
borderColor: '#6366f1',
borderWidth: 2,
borderRadius: 6,
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false }
},
scales: {
y: { beginAtZero: true, ticks: { precision: 0 }, grid: { color: '#f1f5f9' } },
x: { grid: { display: false } }
}
}
});
if (topChart) topChart.destroy();
const lots = data.top_parking_lots ?? [];
topChart = new Chart(document.getElementById('topChart'), {
type: 'doughnut',
data: {
labels: lots.map(l => l.name),
datasets: [{
data: lots.map(l => l.value),
backgroundColor: ['#6366f1','#10b981','#f59e0b','#ef4444','#0ea5e9'],
borderWidth: 3,
borderColor: '#fff',
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { usePointStyle: true, padding: 12, font: { family: 'Cairo', size: 12 } } }
},
cutout: '65%',
}
});
} catch {}
}
async function loadBookings() {
const tbody = document.getElementById('bookings-body');
try {
const { success, data } = await fetch('/api/v1/bookings?per_page=8').then(r => r.json());
if (!success || !data?.data?.length) {
tbody.innerHTML = `<tr><td colspan="6" class="text-center py-4" style="color:#94a3b8;">لا توجد حجوزات</td></tr>`;
return;
}
tbody.innerHTML = data.data.map(b => {
const badge = b.status === 'active'
? '<span class="badge badge-soft-success">نشط</span>'
: '<span class="badge badge-soft-secondary">مكتمل</span>';
return `<tr>
<td><span class="fw-600">${b.parking_lot?.name ?? '--'}</span></td>
<td>${b.customer_name ?? '--'}</td>
<td style="direction:ltr;text-align:right;">${b.phone ?? '--'}</td>
<td>${badge}</td>
<td class="text-sm" style="color:#64748b;">${new Date(b.start_time).toLocaleString('ar-SA')}</td>
<td class="text-sm" style="color:#64748b;">${new Date(b.end_time).toLocaleString('ar-SA')}</td>
</tr>`;
}).join('');
} catch {
tbody.innerHTML = `<tr><td colspan="6" class="text-center py-3 text-sm" style="color:#94a3b8;">تعذّر تحميل البيانات</td></tr>`;
}
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString('ar-SA');
}
loadStats();
loadCharts();
loadBookings();
setInterval(() => { loadStats(); loadCharts(); loadBookings(); }, 30000);
</script>
@endpush
@endsection

View File

@ -0,0 +1,447 @@
@extends('layouts.admin')
@section('title', 'إدارة المواقف — دمشق باركينغ')
@section('page-title', 'إدارة المواقف')
@section('styles')
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
#lotsMap { height: 380px; 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; }
/* keep Leaflet tiles crisp inside RTL layout */
.leaflet-container { direction: ltr; }
</style>
@endsection
@section('content')
{{-- ── Header ──────────────────────────────────────────────────────────────── --}}
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
<div>
<h2 class="fw-800 mb-1" style="font-size:1.15rem;color:#0f172a;">مواقف السيارات</h2>
<p class="text-sm mb-0" style="color:#64748b;">
{{ $parkingLots->total() }} موقف مسجّل في النظام
</p>
</div>
<button class="btn fw-600"
style="background:#6366f1;color:#fff;border:none;border-radius:.5rem;padding:.55rem 1.25rem;font-family:'Cairo',sans-serif;"
data-bs-toggle="modal" data-bs-target="#lotModal" id="addLotBtn">
<i class="bi bi-plus-lg me-1"></i>
إضافة موقف
</button>
</div>
{{-- ── Overview Map ─────────────────────────────────────────────────────────── --}}
<div class="card mb-4">
<div class="card-header">
<span class="fw-700 text-sm">
<i class="bi bi-map me-1" style="color:#6366f1;"></i>
خريطة المواقف
</span>
</div>
<div class="p-2">
<div id="lotsMap"></div>
</div>
</div>
{{-- ── Table Card ───────────────────────────────────────────────────────────── --}}
<div class="card">
{{-- 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 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="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>
</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>
</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>
{{-- ── Add / Edit Modal ────────────────────────────────────────────────────── --}}
<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-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">
@csrf
<input type="hidden" id="lotId">
<div class="modal-body p-4">
<div class="row g-3">
{{-- Basic Info --}}
<div class="col-12">
<p class="text-xs fw-700 text-uppercase mb-2" style="color:#94a3b8;letter-spacing:.05em;">
المعلومات الأساسية
</p>
</div>
<div class="col-md-6">
<label class="form-label">اسم الموقف <span style="color:#ef4444;">*</span></label>
<input type="text" name="name" id="f_name" class="form-control" required>
</div>
<div class="col-md-3">
<label class="form-label">السعة <span style="color:#ef4444;">*</span></label>
<input type="number" name="total_capacity" id="f_capacity" class="form-control" min="1" required>
</div>
<div class="col-md-3">
<label class="form-label">السعر / ساعة <span style="color:#ef4444;">*</span></label>
<input type="number" name="price_per_hour" id="f_price" class="form-control" step="0.01" min="0" required>
</div>
<div class="col-md-6">
<label class="form-label">ساعات العمل <span style="color:#ef4444;">*</span></label>
<input type="text" name="working_hours" id="f_hours" class="form-control" value="24/7" required>
</div>
{{-- Location --}}
<div class="col-12 mt-2">
<p class="text-xs fw-700 text-uppercase mb-2" style="color:#94a3b8;letter-spacing:.05em;">
الموقع الجغرافي
</p>
</div>
<div class="col-md-3">
<label class="form-label">خط العرض <span style="color:#ef4444;">*</span></label>
<input type="number" name="latitude" id="f_lat" class="form-control" step="any" required dir="ltr">
</div>
<div class="col-md-3">
<label class="form-label">خط الطول <span style="color:#ef4444;">*</span></label>
<input type="number" name="longitude" id="f_lng" class="form-control" step="any" required dir="ltr">
</div>
<div class="col-md-6">
<label class="form-label">العنوان الكامل <span style="color:#ef4444;">*</span></label>
<input type="text" name="address" id="f_address" class="form-control" required>
</div>
{{-- Map Picker --}}
<div class="col-12">
<label class="form-label mb-1">
<i class="bi bi-cursor-fill me-1" style="color:#6366f1;"></i>
انقر على الخريطة لتحديد الموقع
</label>
<div id="modalMap"></div>
<p class="map-hint">انقر على أي نقطة في الخريطة لتعبئة إحداثيات خط العرض والطول تلقائياً.</p>
</div>
</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="submit" id="submitBtn"
class="btn btn-sm fw-600"
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="submitText">حفظ الموقف</span>
</button>
</div>
</form>
</div>
</div>
</div>
@push('scripts')
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ── Parking lots data from server ─────────────────────────────────────────
const lotsData = @json($parkingLots->items());
// Damascus city centre fallback
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 ──────────────────────────────────────────────────────
let modalMap = null;
let modalMarker = null;
function initModalMap(lat, lng) {
const center = (lat && lng) ? [lat, lng] : DAMASCUS;
const zoom = (lat && lng) ? 15 : 13;
if (!modalMap) {
modalMap = L.map('modalMap').setView(center, zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19
}).addTo(modalMap);
modalMap.on('click', e => {
const { lat, lng } = e.latlng;
document.getElementById('f_lat').value = lat.toFixed(7);
document.getElementById('f_lng').value = lng.toFixed(7);
placeModalMarker(lat, lng);
});
} else {
modalMap.setView(center, zoom);
}
if (lat && lng) {
placeModalMarker(lat, lng);
} else if (modalMarker) {
modalMap.removeLayer(modalMarker);
modalMarker = null;
}
// Must invalidate after modal finishes animating
setTimeout(() => modalMap.invalidateSize(), 350);
}
function placeModalMarker(lat, lng) {
if (modalMarker) {
modalMarker.setLatLng([lat, lng]);
} else {
modalMarker = L.marker([lat, lng], { draggable: true }).addTo(modalMap);
modalMarker.on('dragend', e => {
const pos = e.target.getLatLng();
document.getElementById('f_lat').value = pos.lat.toFixed(7);
document.getElementById('f_lng').value = pos.lng.toFixed(7);
});
}
}
// Sync map marker when user types into lat/lng fields
['f_lat', 'f_lng'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
const lat = parseFloat(document.getElementById('f_lat').value);
const lng = parseFloat(document.getElementById('f_lng').value);
if (!isNaN(lat) && !isNaN(lng) && modalMap) {
modalMap.setView([lat, lng], 15);
placeModalMarker(lat, lng);
}
});
});
// ── Bootstrap modal events ────────────────────────────────────────────────
const lotModalEl = document.getElementById('lotModal');
lotModalEl.addEventListener('shown.bs.modal', () => {
const lat = parseFloat(document.getElementById('f_lat').value) || null;
const lng = parseFloat(document.getElementById('f_lng').value) || null;
initModalMap(lat, lng);
});
// ── Modal logic ───────────────────────────────────────────────────────────
let editingId = null;
const modal = new bootstrap.Modal(lotModalEl, { backdrop: 'static' });
document.getElementById('addLotBtn').onclick = () => {
editingId = null;
document.getElementById('lotForm').reset();
document.getElementById('modalLabel').textContent = 'إضافة موقف جديد';
document.getElementById('submitText').textContent = 'حفظ الموقف';
modal.show();
};
async function editLot(id) {
editingId = id;
setBtnLoading(true);
try {
const { data } = await fetch(`/admin/parking-lots/${id}`).then(r => r.json());
document.getElementById('modalLabel').textContent = 'تعديل: ' + data.name;
document.getElementById('submitText').textContent = 'تحديث الموقف';
document.getElementById('f_name').value = data.name;
document.getElementById('f_capacity').value = data.total_capacity;
document.getElementById('f_price').value = data.price_per_hour;
document.getElementById('f_hours').value = data.working_hours;
document.getElementById('f_lat').value = data.latitude;
document.getElementById('f_lng').value = data.longitude;
document.getElementById('f_address').value = data.address;
modal.show();
} catch { alert('خطأ في تحميل البيانات'); }
finally { setBtnLoading(false); }
}
document.getElementById('lotForm').onsubmit = async (e) => {
e.preventDefault();
setBtnLoading(true);
try {
const res = await fetch(editingId ? `/admin/parking-lots/${editingId}` : '/admin/parking-lots', {
method: editingId ? 'PUT' : 'POST',
body: new FormData(e.target)
});
const result = await res.json();
result.success ? location.reload() : alert(result.message || 'خطأ في العملية');
} catch { alert('خطأ في الاتصال'); }
finally { setBtnLoading(false); }
};
function setBtnLoading(on) {
document.getElementById('submitBtn').disabled = on;
document.getElementById('submitSpinner').classList.toggle('d-none', !on);
}
function toggleStatus(id) {
if (!confirm('تغيير حالة الموقف؟')) return;
fetch(`/admin/parking-lots/${id}/toggle`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content }
}).then(r => r.json()).then(d => d.success ? location.reload() : alert(d.message));
}
document.getElementById('searchInput').addEventListener('keypress', e => {
if (e.key === 'Enter') doSearch();
});
function doSearch() {
const t = document.getElementById('searchInput').value;
window.location.href = `/admin/parking-lots?search=${encodeURIComponent(t)}`;
}
</script>
@endpush
@endsection

View File

@ -0,0 +1,87 @@
@extends('layouts.auth')
@section('title', 'تسجيل الدخول — دمشق باركينغ')
@section('content')
<h2 class="fw-800 mb-1" style="font-size:1.35rem;color:#0f172a;">تسجيل الدخول</h2>
<p class="text-sm mb-4" style="color:#64748b;">أدخل بياناتك للوصول إلى لوحة التحكم</p>
@if($errors->any())
<div class="alert alert-danger border-0 rounded-3 d-flex align-items-center gap-2 py-2 mb-4">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0"></i>
<span class="text-sm">{{ $errors->first() }}</span>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
</div>
@endif
<form method="POST" action="{{ route('login') }}">
@csrf
<div class="mb-3">
<label class="form-label">البريد الإلكتروني</label>
<div class="input-group">
<span class="input-group-text" style="background:#f8fafc;border-color:#e2e8f0;border-left:none;">
<i class="bi bi-envelope" style="color:#94a3b8;"></i>
</span>
<input type="email" name="email" value="{{ old('email') }}" required
class="form-control @error('email') is-invalid @enderror"
style="border-right:none;border-color:#e2e8f0;"
placeholder="your@email.com" dir="ltr">
@error('email')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0">كلمة السر</label>
<a href="#" class="text-xs" style="color:#6366f1;text-decoration:none;">نسيت كلمة السر؟</a>
</div>
<div class="input-group">
<span class="input-group-text" style="background:#f8fafc;border-color:#e2e8f0;border-left:none;">
<i class="bi bi-lock" style="color:#94a3b8;"></i>
</span>
<input type="password" name="password" required
class="form-control @error('password') is-invalid @enderror"
style="border-right:none;border-color:#e2e8f0;"
placeholder="••••••••" dir="ltr">
@error('password')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
</div>
<div class="d-flex align-items-center mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="remember" id="remember">
<label class="form-check-label text-sm" for="remember" style="color:#64748b;">تذكرني</label>
</div>
</div>
<button type="submit"
class="btn btn-lg w-100 fw-700"
style="background:#6366f1;color:#fff;border:none;font-family:'Cairo',sans-serif;border-radius:.5rem;">
<i class="bi bi-box-arrow-in-left me-2"></i>
دخول
</button>
</form>
<div class="auth-divider"><span>أو</span></div>
<a href="{{ route('register') }}"
class="btn btn-lg w-100 fw-600"
style="background:#f8fafc;color:#0f172a;border:1px solid #e2e8f0;font-family:'Cairo',sans-serif;border-radius:.5rem;">
<i class="bi bi-person-plus me-2"></i>
إنشاء حساب جديد
</a>
<div class="mt-4 p-3 rounded-3" style="background:#f0f9ff;border:1px solid #bae6fd;">
<p class="text-xs fw-700 mb-2" style="color:#0369a1;">
<i class="bi bi-key-fill me-1"></i>حسابات الاختبار
</p>
<p class="text-xs mb-1" style="color:#0369a1;">
<strong>مدير:</strong> admin@damascusparking.com / admin123
</p>
<p class="text-xs mb-0" style="color:#0369a1;">
<strong>مشغّل:</strong> operator@damascusparking.com / operator123
</p>
</div>
@endsection

View File

@ -0,0 +1,92 @@
@extends('layouts.auth')
@section('title', 'إنشاء حساب — دمشق باركينغ')
@section('content')
<h2 class="fw-800 mb-1" style="font-size:1.35rem;color:#0f172a;">إنشاء حساب جديد</h2>
<p class="text-sm mb-4" style="color:#64748b;">انضم إلى دمشق باركينغ الآن</p>
@if($errors->any())
<div class="alert alert-danger border-0 rounded-3 d-flex align-items-center gap-2 py-2 mb-4">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0"></i>
<span class="text-sm">{{ $errors->first() }}</span>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
</div>
@endif
<form method="POST" action="{{ route('register.action') }}">
@csrf
<div class="mb-3">
<label class="form-label">الاسم الكامل</label>
<div class="input-group">
<span class="input-group-text" style="background:#f8fafc;border-color:#e2e8f0;border-left:none;">
<i class="bi bi-person" style="color:#94a3b8;"></i>
</span>
<input type="text" name="name" value="{{ old('name') }}" required
class="form-control @error('name') is-invalid @enderror"
style="border-right:none;border-color:#e2e8f0;"
placeholder="الاسم الكامل">
@error('name')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
</div>
<div class="mb-3">
<label class="form-label">البريد الإلكتروني</label>
<div class="input-group">
<span class="input-group-text" style="background:#f8fafc;border-color:#e2e8f0;border-left:none;">
<i class="bi bi-envelope" style="color:#94a3b8;"></i>
</span>
<input type="email" name="email" value="{{ old('email') }}" required
class="form-control @error('email') is-invalid @enderror"
style="border-right:none;border-color:#e2e8f0;"
placeholder="your@email.com" dir="ltr">
@error('email')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
</div>
<div class="mb-3">
<label class="form-label">كلمة السر</label>
<div class="input-group">
<span class="input-group-text" style="background:#f8fafc;border-color:#e2e8f0;border-left:none;">
<i class="bi bi-lock" style="color:#94a3b8;"></i>
</span>
<input type="password" name="password" required
class="form-control @error('password') is-invalid @enderror"
style="border-right:none;border-color:#e2e8f0;"
placeholder="••••••••" dir="ltr">
@error('password')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
</div>
<div class="mb-4">
<label class="form-label">تأكيد كلمة السر</label>
<div class="input-group">
<span class="input-group-text" style="background:#f8fafc;border-color:#e2e8f0;border-left:none;">
<i class="bi bi-lock-fill" style="color:#94a3b8;"></i>
</span>
<input type="password" name="password_confirmation" required
class="form-control"
style="border-right:none;border-color:#e2e8f0;"
placeholder="••••••••" dir="ltr">
</div>
</div>
<button type="submit"
class="btn btn-lg w-100 fw-700"
style="background:#059669;color:#fff;border:none;font-family:'Cairo',sans-serif;border-radius:.5rem;">
<i class="bi bi-person-check me-2"></i>
إنشاء الحساب
</button>
</form>
<div class="auth-divider"><span>أو</span></div>
<a href="{{ route('login') }}"
class="btn btn-lg w-100 fw-600"
style="background:#f8fafc;color:#0f172a;border:1px solid #e2e8f0;font-family:'Cairo',sans-serif;border-radius:.5rem;">
<i class="bi bi-box-arrow-in-left me-2"></i>
تسجيل الدخول
</a>
@endsection

649
resources/views/dp.html Normal file
View File

@ -0,0 +1,649 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#2c3e50">
<meta name="referrer" content="strict-origin-when-cross-origin">
<title>مواقف السيارات في دمشق</title>
<!-- Leaflet CSS for interactive map -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
-webkit-tap-highlight-color: transparent;
}
input, button { -webkit-user-select: text; user-select: text; }
body {
background-color: #f8f9fa;
color: #333;
line-height: 1.6;
padding-bottom: 70px;
padding-bottom: calc(70px + env(safe-area-inset-bottom));
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
}
/* ===== HEADER ===== */
header {
background: linear-gradient(135deg, #2c3e50, #4a6491);
color: white;
padding: 16px 0;
text-align: center;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-bottom: 4px solid #e74c3c;
}
.header-content {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.logo { font-size: 24px; }
h1 { font-size: 22px; font-weight: 700; }
.subtitle {
text-align: center;
margin: 12px 0 20px;
color: #7f8c8d;
font-size: 14px;
}
/* ===== SEARCH BAR ===== */
.search-bar {
display: flex;
margin: 16px 0;
gap: 8px;
background: white;
padding: 8px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
}
.search-bar input {
flex: 1;
padding: 12px 16px;
border: 1px solid #e1e8ed;
border-radius: 8px;
font-size: 15px;
}
.search-bar button {
background: linear-gradient(135deg, #3498db, #2980b9);
color: white;
border: none;
padding: 12px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
min-width: 70px;
}
/* ===== CONTENT LAYOUT - DESKTOP DEFAULT ===== */
.content {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.parking-list {
flex: 1;
min-width: 300px;
}
.section-title {
font-size: 19px;
margin: 10px 0 15px;
color: #2c3e50;
padding-bottom: 8px;
border-bottom: 2px solid #e74c3c;
display: inline-block;
}
.parking-item {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 14px;
box-shadow: 0 3px 10px rgba(0,0,0,0.08);
border-right: 4px solid #3498db;
cursor: pointer;
transition: background 0.2s;
}
.parking-item:active { background: #f8f9ff; }
.parking-name {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
color: #2c3e50;
display: flex;
align-items: center;
gap: 8px;
}
.parking-info {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
flex-wrap: wrap;
gap: 8px;
font-size: 14px;
}
.info-item { display: flex; align-items: center; gap: 4px; color: #555; }
.available, .full, .limited {
font-weight: bold;
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
}
.available { color: #27ae60; background: rgba(39, 174, 96, 0.12); }
.full { color: #e74c3c; background: rgba(231, 76, 60, 0.12); }
.limited { color: #f39c12; background: rgba(243, 156, 18, 0.12); }
/* ===== MAP CONTAINER ===== */
.map-toggle-btn {
display: none;
width: 100%;
padding: 12px;
background: #3498db;
color: white;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
cursor: pointer;
}
.map-container {
height: 400px;
background-color: #e0e0e0;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 3px 10px rgba(0,0,0,0.08);
}
#map { width: 100%; height: 100%; }
/* ===== MODAL ===== */
.details-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
z-index: 1000;
align-items: flex-end;
justify-content: center;
}
.modal-content {
background-color: white;
width: 100%;
max-width: 550px;
border-radius: 20px 20px 0 0;
padding: 24px 20px 30px;
position: relative;
max-height: 85vh;
overflow-y: auto;
}
@media (min-width: 768px) {
.details-modal { align-items: center; }
.modal-content { border-radius: 16px; }
}
.close-btn {
position: absolute;
top: 12px;
left: 12px;
font-size: 26px;
cursor: pointer;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f1f2f6;
}
.modal-title {
font-size: 21px;
margin-bottom: 18px;
color: #2c3e50;
padding-left: 40px;
font-weight: 700;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid #f1f2f6;
font-size: 15px;
}
.info-label { color: #7f8c8d; }
.info-value { color: #2c3e50; font-weight: 600; }
.reserve-btn {
background: linear-gradient(135deg, #27ae60, #2ecc71);
color: white;
border: none;
padding: 16px;
border-radius: 12px;
cursor: pointer;
font-size: 17px;
font-weight: 700;
width: 100%;
margin-top: 10px;
}
.reserve-btn:disabled {
background: #bdc3c7;
cursor: not-allowed;
}
/* ===== FOOTER NAV (YouTube-style) ===== */
.app-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-top: 1px solid #e1e8ed;
display: flex;
justify-content: space-around;
align-items: center;
padding: 8px 0 calc(12px + env(safe-area-inset-bottom));
z-index: 200;
box-shadow: 0 -2px 12px rgba(0,0,0,0.08);
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
color: #7f8c8d;
font-size: 11px;
text-decoration: none;
}
.nav-item.active { color: #3498db; background: rgba(52, 152, 219, 0.1); }
.nav-icon { font-size: 22px; }
/* ===== 📱 MOBILE-ONLY STYLES (max-width: 767px) ===== */
@media (max-width: 767px) {
.content {
flex-direction: column;
}
.parking-list {
order: 1;
min-width: 100%;
}
.map-toggle-btn {
display: block;
}
.map-container {
height: 0;
overflow: hidden;
transition: height 0.3s ease;
}
.map-container.expanded {
height: 300px;
}
.modal-content {
border-radius: 16px 16px 0 0;
margin: 0 12px;
}
.nav-text { display: none; }
.nav-item { min-width: 50px; }
}
/* ===== DESKTOP: Hide app footer, show classic footer ===== */
@media (min-width: 1024px) {
.app-footer { display: none; }
body { padding-bottom: 0; }
.footer {
text-align: center;
margin-top: 40px;
padding: 24px;
color: #7f8c8d;
border-top: 1px solid #ecf0f1;
display: block;
}
}
.footer { display: none; }
</style>
</head>
<body>
<header>
<div class="container">
<div class="header-content">
<div class="logo">🅿️</div>
<h1>مواقف السيارات في دمشق</h1>
</div>
</div>
</header>
<div class="container">
<p class="subtitle">ابحث عن أقرب موقف سيارات متاح في دمشق واحجز مكانك بسهولة</p>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="ابحث باسم الموقف أو المنطقة..." autocomplete="off">
<button id="searchBtn">بحث</button>
</div>
<div class="content">
<div class="parking-list">
<button class="map-toggle-btn" id="mapToggleBtn">🗺️ عرض الخريطة</button>
<div class="map-container" id="mapContainer">
<div id="map"></div>
</div>
<h2 class="section-title">مواقف السيارات المتاحة</h2>
<div id="parkingList"></div>
</div>
</div>
</div>
<!-- Details Modal -->
<div class="details-modal" id="detailsModal">
<div class="modal-content">
<span class="close-btn" id="closeModal">&times;</span>
<h2 class="modal-title" id="modalTitle">اسم الموقف</h2>
<div class="modal-info">
<div class="info-row"><span class="info-label">السعة الكلية:</span><span class="info-value" id="modalTotal">0</span></div>
<div class="info-row"><span class="info-label">الأماكن المتاحة:</span><span class="info-value" id="modalAvailable">0</span></div>
<div class="info-row"><span class="info-label">سعر الساعة:</span><span class="info-value" id="modalPrice">0</span></div>
<div class="info-row"><span class="info-label">ساعات العمل:</span><span class="info-value" id="modalHours">24/7</span></div>
<div class="info-row"><span class="info-label">العنوان:</span><span class="info-value" id="modalAddress">...</span></div>
</div>
<button class="reserve-btn" id="reserveBtn">احجز مكان</button>
</div>
</div>
<!-- YouTube-Style Footer Nav -->
<nav class="app-footer">
<a href="#" class="nav-item active" data-page="home"><span class="nav-icon">🏠</span><span class="nav-text">الرئيسية</span></a>
<a href="#" class="nav-item" data-page="map"><span class="nav-icon">🗺️</span><span class="nav-text">الخريطة</span></a>
<a href="#" class="nav-item" data-page="favorites"><span class="nav-icon">❤️</span><span class="nav-text">المفضلة</span></a>
<a href="#" class="nav-item" data-page="history"><span class="nav-icon">🕒</span><span class="nav-text">السجل</span></a>
<a href="#" class="nav-item" data-page="profile"><span class="nav-icon">👤</span><span class="nav-text">حسابي</span></a>
</nav>
<div class="footer">
<div class="container">
<p>تطبيق مواقف السيارات في دمشق &copy; 2026 - جميع الحقوق محفوظة</p>
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const parkingData = [
{ id: 1, name: "موقف ساحة الأمويين", address: "ساحة الأمويين، وسط مدينة دمشق", totalSpaces: 150, availableSpaces: 32, pricePerHour: 500, hours: "24/7", lat: 33.5138, lng: 36.2765 },
{ id: 2, name: "موقف الحرمون", address: "شارع الحرمون، المزة", totalSpaces: 80, availableSpaces: 0, pricePerHour: 300, hours: "06:00 - 00:00", lat: 33.4809, lng: 36.2586 },
{ id: 3, name: "موقف الجامعة", address: "جامعة دمشق، البرامكة", totalSpaces: 200, availableSpaces: 45, pricePerHour: 200, hours: "07:00 - 20:00", lat: 33.5102, lng: 36.2784 },
{ id: 4, name: "موقف الشعلان", address: "حي الشعلان، أبو رمانة", totalSpaces: 60, availableSpaces: 12, pricePerHour: 400, hours: "24/7", lat: 33.5215, lng: 36.2932 },
{ id: 5, name: "موقف المطار", address: "مطار دمشق الدولي", totalSpaces: 300, availableSpaces: 120, pricePerHour: 600, hours: "24/7", lat: 33.4144, lng: 36.5200 },
{ id: 6, name: "موقف التكية السليمانية", address: "التكية السليمانية، المرجة", totalSpaces: 100, availableSpaces: 25, pricePerHour: 350, hours: "08:00 - 22:00", lat: 33.5147, lng: 36.3065 },
{ id: 7, name: "موقف صحيفة تشرين", address: "جادة صحيفة تشرين، المالكي", totalSpaces: 70, availableSpaces: 5, pricePerHour: 450, hours: "07:00 - 23:00", lat: 33.5180, lng: 36.2760 },
{ id: 8, name: "موقف شارع بغداد", address: "شارع بغداد، الجسر الأبيض", totalSpaces: 90, availableSpaces: 40, pricePerHour: 300, hours: "24/7", lat: 33.5210, lng: 36.2900 }
];
const parkingList = document.getElementById('parkingList');
const searchInput = document.getElementById('searchInput');
const searchBtn = document.getElementById('searchBtn');
const detailsModal = document.getElementById('detailsModal');
const closeModal = document.getElementById('closeModal');
const reserveBtn = document.getElementById('reserveBtn');
const mapToggleBtn = document.getElementById('mapToggleBtn');
const mapContainer = document.getElementById('mapContainer');
let map, markers = [];
let currentParking = null;
function getAvailabilityStatus(available, total) {
if (available === 0) return 'full';
if (available < total * 0.2) return 'limited';
return 'available';
}
function formatPrice(price) {
return `${price.toLocaleString('ar-SY')} ل.س`;
}
function initMap() {
map = L.map('map', { attributionControl: false }).setView([33.5138, 36.2765], 12);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
parkingData.forEach(parking => {
const marker = L.marker([parking.lat, parking.lng]).addTo(map);
marker.bindPopup(`
<b>${parking.name}</b><br>
${parking.address}<br>
<span style="color:${parking.availableSpaces > 0 ? '#27ae60' : '#e74c3c'}">
${parking.availableSpaces} متاح من ${parking.totalSpaces}
</span>
`);
marker.parkingId = parking.id;
markers.push(marker);
});
if (markers.length > 0) {
const group = new L.featureGroup(markers);
const bounds = group.getBounds().pad(0.1);
map.fitBounds(bounds, {
padding: [20, 20],
maxZoom: 14
});
} else {
map.setView([33.5138, 36.2765], 13);
}
setTimeout(() => {
if (map) map.invalidateSize();
}, 300);
}
function displayParkingList(parkings) {
parkingList.innerHTML = '';
if (parkings.length === 0) {
parkingList.innerHTML = `
<div class="parking-item" style="text-align:center;padding:30px;background:#f8f9fa">
<p style="font-size:16px;color:#7f8c8d">لا توجد مواقف مطابقة للبحث</p>
<p style="margin-top:8px;font-size:13px;color:#95a5a6">جرب استخدام كلمات بحث أخرى</p>
</div>`;
return;
}
parkings.forEach(parking => {
const status = getAvailabilityStatus(parking.availableSpaces, parking.totalSpaces);
const statusText = {
'available': `${parking.availableSpaces} متاحة`,
'limited': `${parking.availableSpaces} متاحة (محدود)`,
'full': 'ممتلئ'
}[status];
const item = document.createElement('div');
item.className = 'parking-item';
item.innerHTML = `
<div class="parking-name"><span>🅿️</span>${parking.name}</div>
<div class="parking-info">
<div class="info-item"><span>📍</span><span>${parking.address}</span></div>
</div>
<div class="parking-info">
<div class="info-item"><span>🚗</span><span>${parking.totalSpaces} مكان</span></div>
<div class="${status}">${statusText}</div>
</div>
<div class="parking-info">
<div class="info-item"><span>💰</span><span>${formatPrice(parking.pricePerHour)}/ساعة</span></div>
<div class="info-item"><span>🕒</span><span>${parking.hours}</span></div>
</div>
`;
item.addEventListener('click', () => {
showParkingDetails(parking);
if (window.innerWidth < 768 && map) {
map.setView([parking.lat, parking.lng], 14);
if (!mapContainer.classList.contains('expanded')) {
mapContainer.classList.add('expanded');
mapToggleBtn.textContent = '🔼 إخفاء الخريطة';
setTimeout(() => map.invalidateSize(), 300);
}
}
});
parkingList.appendChild(item);
});
}
function showParkingDetails(parking) {
currentParking = parking;
document.getElementById('modalTitle').textContent = parking.name;
document.getElementById('modalTotal').textContent = parking.totalSpaces;
document.getElementById('modalAvailable').textContent = parking.availableSpaces;
document.getElementById('modalPrice').textContent = formatPrice(parking.pricePerHour) + '/ساعة';
document.getElementById('modalHours').textContent = parking.hours;
document.getElementById('modalAddress').textContent = parking.address;
if (parking.availableSpaces > 0) {
reserveBtn.textContent = 'احجز مكان الآن';
reserveBtn.disabled = false;
} else {
reserveBtn.textContent = 'لا توجد أماكن متاحة';
reserveBtn.disabled = true;
}
detailsModal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closeDetailsModal() {
detailsModal.style.display = 'none';
document.body.style.overflow = '';
}
function searchParkings() {
const term = searchInput.value.toLowerCase().trim();
const filtered = term === '' ? parkingData : parkingData.filter(p =>
p.name.toLowerCase().includes(term) ||
p.address.toLowerCase().includes(term)
);
displayParkingList(filtered);
if (map) {
markers.forEach(marker => {
const parking = parkingData.find(p => p.id === marker.parkingId);
if (!parking) return;
if (filtered.some(fp => fp.id === parking.id)) {
if (!map.hasLayer(marker)) marker.addTo(map);
} else {
if (map.hasLayer(marker)) map.removeLayer(marker);
}
});
}
}
function reserveParking() {
if (!currentParking || currentParking.availableSpaces <= 0) return;
currentParking.availableSpaces--;
showParkingDetails(currentParking);
displayParkingList(parkingData);
const marker = markers.find(m => m.parkingId === currentParking.id);
if (marker) {
marker.setPopupContent(`
<b>${currentParking.name}</b><br>
${currentParking.address}<br>
<span style="color:${currentParking.availableSpaces > 0 ? '#27ae60' : '#e74c3c'}">
${currentParking.availableSpaces} متاح من ${currentParking.totalSpaces}
</span>
`);
}
}
mapToggleBtn.addEventListener('click', () => {
const isExpanded = mapContainer.classList.toggle('expanded');
mapToggleBtn.textContent = isExpanded ? '🔼 إخفاء الخريطة' : '🗺️ عرض الخريطة';
if (map) setTimeout(() => map.invalidateSize(), 300);
});
searchBtn.addEventListener('click', searchParkings);
searchInput.addEventListener('keyup', (e) => { if (e.key === 'Enter') searchParkings(); });
closeModal.addEventListener('click', closeDetailsModal);
reserveBtn.addEventListener('click', reserveParking);
detailsModal.addEventListener('click', (e) => { if (e.target === detailsModal) closeDetailsModal(); });
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
const page = item.dataset.page;
if (page === 'map' && window.innerWidth < 768) {
if (!mapContainer.classList.contains('expanded')) {
mapContainer.classList.add('expanded');
mapToggleBtn.textContent = '🔼 إخفاء الخريطة';
if (map) setTimeout(() => map.invalidateSize(), 300);
}
const mapSection = document.getElementById('mapContainer');
if (mapSection) {
mapSection.scrollIntoView({ behavior: 'smooth' });
}
}
});
});
document.addEventListener('DOMContentLoaded', () => {
displayParkingList(parkingData);
setTimeout(initMap, 100);
});
window.addEventListener('resize', () => {
if (map) map.invalidateSize();
});
</script>
</body>
</html>

View File

@ -0,0 +1,441 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>مواقف السيارات في دمشق</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
@vite(['resources/css/app.scss', 'resources/js/app.js'])
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css">
<style>
/* Bootstrap resets max-width:100% on all <img> which breaks Leaflet tiles */
.leaflet-container img { max-width: none !important; box-shadow: none !important; }
.leaflet-container { direction: ltr; }
</style>
</head>
<body style="background:#f1f5f9;font-family:'Cairo',sans-serif;">
{{-- ══ HEADER ══════════════════════════════════════════════════════════════ --}}
<header class="public-header">
<div class="container">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-3">
<div style="width:40px;height:40px;background:#6366f1;border-radius:.625rem;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="bi bi-p-square-fill text-white" style="font-size:1.25rem;"></i>
</div>
<div>
<div class="fw-800" style="color:#f8fafc;font-size:1.05rem;line-height:1.2;">دمشق باركينغ</div>
<div style="color:#94a3b8;font-size:.72rem;">مواقف السيارات في دمشق</div>
</div>
</div>
<div class="d-flex align-items-center gap-2">
@include('partials.user-dropdown')
</div>
</div>
</div>
</header>
{{-- ══ HERO SEARCH ═════════════════════════════════════════════════════════ --}}
<div class="mob-hero-compact" style="background:linear-gradient(135deg,#0f172a 0%,#1e3a5f 100%);padding:2.5rem 0 3rem;">
<div class="container text-center">
<h1 class="fw-800 mb-2 d-none d-md-block" style="color:#f8fafc;font-size:clamp(1.4rem,4vw,2rem);">
ابحث عن موقف سيارات في دمشق
</h1>
<p class="mb-4 d-none d-md-block" style="color:#94a3b8;font-size:.9rem;">
تصفّح المواقف المتاحة، تحقق من الأسعار، واحجز مكانك
</p>
<div class="mx-auto" style="max-width:520px;">
<div class="d-flex gap-2" style="background:#1e293b;padding:.5rem;border-radius:.75rem;">
<input type="text" id="searchInput"
class="form-control"
style="background:transparent;border:none;color:#f8fafc;font-family:'Cairo',sans-serif;box-shadow:none;"
placeholder="اكتب اسم الموقف أو المنطقة...">
<button id="searchBtn"
class="btn fw-600 flex-shrink-0"
style="background:#6366f1;color:#fff;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;padding:.5rem 1.25rem;">
<i class="bi bi-search me-1"></i>بحث
</button>
</div>
</div>
<div id="searchStatus" class="mt-3 d-none">
<span class="badge" style="background:rgba(255,255,255,.1);color:#cbd5e1;padding:.45em .9em;font-size:.8rem;">
نتائج البحث عن "<span id="searchTerm"></span>"
</span>
</div>
</div>
</div>
{{-- ══ MAIN CONTENT ════════════════════════════════════════════════════════ --}}
<div class="container py-4 mob-nav-pad">
<div class="row g-3">
{{-- Parking List (RTL: renders on right side, but we put it second so it's on LEFT visually) --}}
{{-- Actually in RTL, order-1 = appears first = right side --}}
{{-- List should be on LEFT (order-2 = left in RTL), Map on RIGHT (order-1 = right in RTL) --}}
{{-- MAP --}}
<div class="col-lg-8 order-lg-1 mob-section-map">
<div class="card h-100" style="min-height:520px;">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-700 text-sm" style="color:#0f172a;">
<i class="bi bi-map me-1" style="color:#6366f1;"></i>
خريطة المواقف
</span>
<span class="badge badge-soft-primary text-xs" id="map-count">--</span>
</div>
<div class="card-body p-0" style="border-radius:0 0 .75rem .75rem;overflow:hidden;">
<div id="map" style="height:500px;"></div>
</div>
</div>
</div>
{{-- LIST --}}
<div class="col-lg-4 order-lg-2 mob-section-list">
<div class="card mob-sticky-desktop" style="position:sticky;top:1rem;">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-700 text-sm" style="color:#0f172a;">
<i class="bi bi-car-front me-1" style="color:#6366f1;"></i>
المواقف المتاحة
</span>
<span class="badge badge-soft-success text-xs" id="list-count">{{ $lots->count() }} موقف</span>
</div>
<div style="max-height:500px;overflow-y:auto;scrollbar-width:thin;" id="parkingList">
@forelse($lots as $lot)
@php
$avail = $lot['avail'];
$total = $lot['total'];
if ($avail === 0) {
$avCls = 'avail-full';
$avText = 'ممتلئ';
} elseif ($avail < $total * 0.2) {
$avCls = 'avail-limited';
$avText = $avail . ' محدود';
} else {
$avCls = 'avail-open';
$avText = $avail . ' متاح';
}
@endphp
<div class="parking-card p-3 border-bottom" id="card-{{ $lot['id'] }}"
onclick="showModal(lots.find(x=>x.id==={{ $lot['id'] }})); highlightCard({{ $lot['id'] }});">
<div class="d-flex align-items-start justify-content-between gap-2 mb-1">
<span class="fw-700" style="color:#0f172a;font-size:.9rem;">{{ $lot['name'] }}</span>
<span class="badge {{ $avCls }} flex-shrink-0" style="font-size:.72rem;">{{ $avText }}</span>
</div>
<p class="text-xs mb-2" style="color:#94a3b8;">{{ $lot['address'] }}</p>
<div class="d-flex gap-3 text-xs" style="color:#64748b;">
<span><i class="bi bi-car-front me-1"></i>{{ $total }}</span>
<span><i class="bi bi-currency-exchange me-1"></i>{{ number_format($lot['price']) }} ر.س</span>
<span><i class="bi bi-clock me-1"></i>{{ $lot['hours'] }}</span>
</div>
</div>
@empty
<div class="text-center py-5" style="color:#94a3b8;">
<i class="bi bi-p-square d-block mb-2" style="font-size:1.8rem;opacity:.4;"></i>
<span class="text-sm">لا توجد مواقف متاحة</span>
</div>
@endforelse
</div>
</div>
</div>
</div>
</div>
{{-- ══ DETAILS MODAL ═══════════════════════════════════════════════════════ --}}
<div class="modal fade" id="detailsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header" style="border-bottom:1px solid #f1f5f9;">
<h5 class="modal-title fw-800" id="modalName" style="font-size:1rem;color:#0f172a;"></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-3">
{{-- Availability Bar --}}
<div class="mb-3 p-3 rounded-3" style="background:#f8fafc;">
<div class="d-flex justify-content-between mb-1">
<span class="text-xs fw-600" style="color:#64748b;">الإشغال</span>
<span class="text-xs fw-700" id="modalPct" style="color:#0f172a;">--%</span>
</div>
<div class="progress" style="height:8px;border-radius:4px;background:#e2e8f0;">
<div class="progress-bar" id="modalBar" role="progressbar"
style="border-radius:4px;transition:width .5s ease;">
</div>
</div>
<div class="d-flex justify-content-between mt-2">
<div class="text-center">
<div class="fw-800" id="modalAvailable" style="color:#10b981;font-size:1.3rem;line-height:1;">--</div>
<div class="text-xs" style="color:#64748b;">متاح</div>
</div>
<div class="text-center">
<div class="fw-800" id="modalTotal" style="color:#475569;font-size:1.3rem;line-height:1;">--</div>
<div class="text-xs" style="color:#64748b;">إجمالي</div>
</div>
</div>
</div>
{{-- Info Grid --}}
<div class="row g-2">
<div class="col-6">
<div class="p-3 rounded-3" style="background:#f0fdf4;">
<div class="text-xs fw-600 mb-1" style="color:#64748b;">السعر / ساعة</div>
<div class="fw-800" id="modalPrice" style="color:#059669;font-size:1rem;"></div>
</div>
</div>
<div class="col-6">
<div class="p-3 rounded-3" style="background:#f0f9ff;">
<div class="text-xs fw-600 mb-1" style="color:#64748b;">ساعات العمل</div>
<div class="fw-700" id="modalHours" style="color:#0369a1;font-size:.9rem;"></div>
</div>
</div>
<div class="col-12">
<div class="p-3 rounded-3" style="background:#fafafa;">
<div class="text-xs fw-600 mb-1" style="color:#64748b;">
<i class="bi bi-geo-alt me-1"></i>العنوان
</div>
<div class="text-sm" id="modalAddress" style="color:#475569;"></div>
</div>
</div>
</div>
</div>
<div class="modal-footer" style="border-top:1px solid #f1f5f9;gap:.5rem;">
<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="reserveBtn"
class="btn btn-sm fw-700"
style="background:#10b981;color:#fff;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;padding:.4rem 1.25rem;">
<i class="bi bi-calendar-check me-1"></i>احجز الآن
</button>
</div>
</div>
</div>
</div>
{{-- ══ MOBILE BOTTOM NAV ══════════════════════════════════════════════════ --}}
<nav class="mobile-bottom-nav" aria-label="التنقل الرئيسي">
<a href="#" class="mob-nav-item active" data-tab="list"
onclick="switchMobSection('list'); return false;">
<i class="bi bi-list-ul"></i>
<span>المواقف</span>
</a>
<a href="#" class="mob-nav-item" data-tab="map"
onclick="switchMobSection('map'); return false;">
<i class="bi bi-map-fill"></i>
<span>الخريطة</span>
</a>
<a href="#" class="mob-nav-item" data-tab="search"
onclick="switchMobSection('list'); window.scrollTo({top:0,behavior:'smooth'}); setTimeout(()=>document.getElementById('searchInput').focus(),280); return false;">
<i class="bi bi-search"></i>
<span>بحث</span>
</a>
@auth
<a href="{{ route('user.dashboard') }}" class="mob-nav-item">
<div style="width:26px;height:26px;background:#6366f1;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:.7rem;color:#fff;margin-bottom:1px;">
{{ mb_substr(auth()->user()->name, 0, 1) }}
</div>
<span>حسابي</span>
</a>
@else
<a href="{{ route('login') }}" class="mob-nav-item">
<i class="bi bi-person-fill"></i>
<span>الدخول</span>
</a>
@endauth
</nav>
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ── Parking Lots (from database) ──────────────────────────────────────
const lots = {{ Js::from($lots) }};
// ── Helpers ───────────────────────────────────────────────────────────
function availability(l) {
if (l.avail === 0) return { cls: 'avail-full', text: 'ممتلئ', pct: 100 };
if (l.avail < l.total * .2) return { cls: 'avail-limited', text: `${l.avail} محدود`, pct: Math.round((l.total - l.avail) / l.total * 100) };
return { cls: 'avail-open', text: `${l.avail} متاح`, pct: Math.round((l.total - l.avail) / l.total * 100) };
}
const fmtPrice = p => new Intl.NumberFormat('ar-SY').format(p) + ' ر.س';
// ── Map (lazy-init on mobile) ─────────────────────────────────────────
let map = null, mapReady = false;
function initMap() {
if (mapReady) { map?.invalidateSize(); return; }
mapReady = true;
map = L.map('map').setView([33.5138, 36.2765], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap'
}).addTo(map);
lots.forEach(l => {
const av = availability(l);
const col = l.avail === 0 ? '#ef4444' : (l.avail < l.total * .2 ? '#f59e0b' : '#10b981');
const icon = L.divIcon({
html: `<div style="width:36px;height:36px;background:${col};border:3px solid #fff;border-radius:50% 50% 50% 0;transform:rotate(-45deg);box-shadow:0 2px 8px rgba(0,0,0,.25);"></div>`,
iconSize: [36, 36],
iconAnchor: [18, 36],
className: ''
});
const m = L.marker([l.lat, l.lng], { icon }).addTo(map);
m.bindPopup(`
<div style="font-family:'Cairo',sans-serif;min-width:170px;padding:4px;">
<strong style="font-size:.95rem;color:#0f172a;">${l.name}</strong>
<p style="margin:4px 0;font-size:.78rem;color:#64748b;">${l.address}</p>
<span style="background:${col}22;color:${col};padding:2px 8px;border-radius:99px;font-size:.78rem;font-weight:700;">${av.text}</span>
<hr style="margin:6px 0;border-color:#f1f5f9;">
<span style="font-size:.78rem;color:#64748b;">💰 ${fmtPrice(l.price)} / ساعة</span>
</div>
`);
m.on('click', () => showModal(l));
});
if (lots.length) {
const group = L.featureGroup(lots.map(l => L.marker([l.lat, l.lng])));
map.fitBounds(group.getBounds().pad(.1));
}
document.getElementById('map-count').textContent = lots.length + ' موقف';
}
// ── Mobile Section Switch ─────────────────────────────────────────────
function switchMobSection(name) {
const mapEl = document.querySelector('.mob-section-map');
const listEl = document.querySelector('.mob-section-list');
if (name === 'map') {
listEl?.classList.add('mob-hidden');
mapEl?.classList.remove('mob-hidden');
setTimeout(initMap, 150);
} else {
mapEl?.classList.add('mob-hidden');
listEl?.classList.remove('mob-hidden');
}
document.querySelectorAll('.mob-nav-item[data-tab]').forEach(b => {
b.classList.toggle('active', b.dataset.tab === name);
});
}
// ── List ──────────────────────────────────────────────────────────────
let activeId = null;
function renderList(data) {
const el = document.getElementById('parkingList');
document.getElementById('list-count').textContent = data.length + ' موقف';
if (!data.length) {
el.innerHTML = `
<div class="text-center py-5" style="color:#94a3b8;">
<i class="bi bi-search d-block mb-2" style="font-size:1.8rem;opacity:.4;"></i>
<span class="text-sm">لا توجد نتائج</span>
</div>`;
return;
}
el.innerHTML = data.map(l => {
const av = availability(l);
return `
<div class="parking-card p-3 border-bottom" id="card-${l.id}"
onclick="showModal(lots.find(x=>x.id===${l.id})); highlightCard(${l.id});">
<div class="d-flex align-items-start justify-content-between gap-2 mb-1">
<span class="fw-700" style="color:#0f172a;font-size:.9rem;">${l.name}</span>
<span class="badge ${av.cls} flex-shrink-0" style="font-size:.72rem;">${av.text}</span>
</div>
<p class="text-xs mb-2" style="color:#94a3b8;">${l.address}</p>
<div class="d-flex gap-3 text-xs" style="color:#64748b;">
<span><i class="bi bi-car-front me-1"></i>${l.total}</span>
<span><i class="bi bi-currency-exchange me-1"></i>${fmtPrice(l.price)}</span>
<span><i class="bi bi-clock me-1"></i>${l.hours}</span>
</div>
</div>`;
}).join('');
}
function highlightCard(id) {
if (activeId) document.getElementById('card-' + activeId)?.classList.remove('active-card');
activeId = id;
document.getElementById('card-' + id)?.classList.add('active-card');
}
// ── Modal ─────────────────────────────────────────────────────────────
// Bootstrap is deferred (loaded as ES module), so we lazy-init the
// modal on first use rather than at script parse time.
let modal = null;
function getModal() {
if (!modal) modal = new bootstrap.Modal(document.getElementById('detailsModal'));
return modal;
}
let current = null;
function showModal(l) {
current = l;
const av = availability(l);
const pct = Math.round((l.total - l.avail) / l.total * 100);
const barCol = l.avail === 0 ? '#ef4444' : (l.avail < l.total * .2 ? '#f59e0b' : '#10b981');
document.getElementById('modalName').textContent = l.name;
document.getElementById('modalAvailable').textContent = l.avail;
document.getElementById('modalTotal').textContent = l.total;
document.getElementById('modalPrice').textContent = fmtPrice(l.price);
document.getElementById('modalHours').textContent = l.hours;
document.getElementById('modalAddress').textContent = l.address;
document.getElementById('modalPct').textContent = pct + '%';
document.getElementById('modalBar').style.cssText = `width:${pct}%;background:${barCol};border-radius:4px;`;
const btn = document.getElementById('reserveBtn');
if (l.avail > 0) {
btn.disabled = false;
btn.style.background = '#10b981';
btn.innerHTML = '<i class="bi bi-calendar-check me-1"></i>احجز الآن';
} else {
btn.disabled = true;
btn.style.background = '#94a3b8';
btn.innerHTML = '<i class="bi bi-x-circle me-1"></i>ممتلئ';
}
if (map) map.setView([l.lat, l.lng], 16);
getModal().show();
}
// ── Reserve ───────────────────────────────────────────────────────────
document.getElementById('reserveBtn').addEventListener('click', () => {
if (!current || current.avail <= 0) return;
alert(`تم حجز مكان في ${current.name}!\nالعنوان: ${current.address}\nالسعر: ${fmtPrice(current.price)} / ساعة`);
current.avail--;
getModal().hide();
renderList(lots);
});
// ── Search ────────────────────────────────────────────────────────────
function doSearch() {
const term = document.getElementById('searchInput').value.trim().toLowerCase();
const res = term
? lots.filter(l => l.name.includes(term) || l.address.includes(term))
: lots;
renderList(res);
const status = document.getElementById('searchStatus');
document.getElementById('searchTerm').textContent = term;
status.classList.toggle('d-none', !term);
}
document.getElementById('searchBtn').addEventListener('click', doSearch);
document.getElementById('searchInput').addEventListener('keypress', e => {
if (e.key === 'Enter') doSearch();
});
// ── Init ──────────────────────────────────────────────────────────────
document.getElementById('map-count').textContent = lots.length + ' موقف';
if (window.innerWidth >= 768) {
initMap(); // Desktop: initialize immediately
} else {
// Mobile: show list first, lazy-init map when map tab is tapped
document.querySelector('.mob-section-map')?.classList.add('mob-hidden');
}
</script>
</body>
</html>

View File

@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'دمشق باركينغ')</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
@vite(['resources/css/app.scss', 'resources/js/app.js'])
@yield('styles')
</head>
<body>
<div class="app-layout">
{{-- ════════════════════════════════════════
SIDEBAR (appears on the RIGHT in RTL
because it is the first flex child)
════════════════════════════════════════ --}}
<aside class="app-sidebar" id="appSidebar">
{{-- Logo --}}
<a href="{{ route('admin.dashboard') }}" class="sidebar-logo">
<div class="logo-icon">
<i class="bi bi-p-square-fill"></i>
</div>
<div>
<div class="logo-text">دمشق باركينغ</div>
<div class="logo-sub">لوحة الإدارة</div>
</div>
</a>
{{-- Navigation --}}
<nav class="sidebar-nav">
<div class="sidebar-section">الرئيسية</div>
<a href="{{ route('admin.dashboard') }}"
class="sidebar-link {{ request()->routeIs('admin.dashboard') ? 'active' : '' }}">
<i class="bi bi-speedometer2 sidebar-icon"></i>
<span>لوحة التحكم</span>
</a>
<div class="sidebar-section">المواقف والحجوزات</div>
<a href="{{ route('admin.parking-lots.index') }}"
class="sidebar-link {{ request()->routeIs('admin.parking-lots.*') ? 'active' : '' }}">
<i class="bi bi-buildings sidebar-icon"></i>
<span>المواقف</span>
</a>
<a href="{{ route('admin.bookings.active') }}"
class="sidebar-link {{ request()->routeIs('admin.bookings.*') ? 'active' : '' }}">
<i class="bi bi-calendar-check sidebar-icon"></i>
<span>الحجوزات النشطة</span>
</a>
<div class="sidebar-section">التشغيل</div>
<a href="{{ route('operator.dashboard') }}"
class="sidebar-link {{ request()->routeIs('operator.*') ? 'active' : '' }}">
<i class="bi bi-person-badge sidebar-icon"></i>
<span>لوحة المشغّل</span>
</a>
</nav>
{{-- Footer: user info + logout --}}
<div class="sidebar-footer">
<div class="d-flex align-items-center gap-2 mb-2">
<div class="user-avatar">
{{ mb_substr(auth()->user()?->name ?? 'م', 0, 1) }}
</div>
<div style="min-width:0">
<div class="user-name text-truncate">{{ auth()->user()?->name ?? 'المستخدم' }}</div>
<div class="user-role">
{{ auth()->user()?->role === 'admin' ? 'مدير النظام' : 'مشغّل' }}
</div>
</div>
</div>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit"
class="btn btn-sm w-100 mt-1 text-start"
style="background:rgba(239,68,68,.12);color:#f87171;border:none;border-radius:.5rem;padding:.45rem .75rem;font-family:'Cairo',sans-serif;font-size:.82rem;">
<i class="bi bi-box-arrow-left me-2"></i>تسجيل الخروج
</button>
</form>
</div>
</aside>
{{-- ════════════════════════════════════════
MAIN BODY
════════════════════════════════════════ --}}
<div class="app-body">
{{-- Topbar --}}
<header class="app-topbar">
<div class="d-flex align-items-center gap-3">
<button class="sidebar-toggle" id="sidebarToggle" aria-label="قائمة التنقل">
<i class="bi bi-list"></i>
</button>
<h1 class="topbar-title">@yield('page-title', 'لوحة التحكم')</h1>
</div>
<div class="topbar-actions d-flex align-items-center gap-2">
{{-- Desktop: link to public site --}}
<a href="{{ route('parking.index') }}"
class="btn btn-sm d-none d-md-inline-flex align-items-center"
style="background:#f1f5f9;color:#475569;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;"
target="_blank">
<i class="bi bi-globe2 me-1"></i>الموقع العام
</a>
{{-- Mobile: user avatar + logout --}}
<div class="d-flex d-md-none align-items-center gap-2">
<div style="width:30px;height:30px;background:rgba(99,102,241,.12);border-radius:50%;display:flex;align-items:center;justify-content:center;color:#6366f1;font-weight:800;font-size:.82rem;flex-shrink:0;">
{{ mb_substr(auth()->user()?->name ?? 'م', 0, 1) }}
</div>
<form method="POST" action="{{ route('logout') }}" style="margin:0;">
@csrf
<button type="submit"
style="background:none;border:none;color:#94a3b8;padding:4px 6px;font-size:1.15rem;cursor:pointer;line-height:1;"
title="تسجيل الخروج">
<i class="bi bi-box-arrow-left"></i>
</button>
</form>
</div>
</div>
</header>
{{-- Flash Messages --}}
<div class="px-4 pt-3">
@if(session('success'))
<div class="alert alert-success d-flex align-items-center gap-2 border-0 rounded-3 py-2 mb-0"
role="alert">
<i class="bi bi-check-circle-fill flex-shrink-0"></i>
<span>{{ session('success') }}</span>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger d-flex align-items-center gap-2 border-0 rounded-3 py-2 mb-0"
role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0"></i>
<span>{{ session('error') }}</span>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
</div>
@endif
</div>
{{-- Page Content --}}
<main class="app-content">
@yield('content')
</main>
</div>{{-- /app-body --}}
</div>{{-- /app-layout --}}
{{-- Mobile sidebar overlay --}}
<div class="sidebar-overlay" id="sidebarOverlay"></div>
{{-- ══ MOBILE BOTTOM NAVIGATION ═══════════════════════════════════════════════ --}}
<nav class="mobile-bottom-nav" aria-label="التنقل">
<a href="{{ route('admin.dashboard') }}"
class="mob-nav-item {{ request()->routeIs('admin.dashboard') ? 'active' : '' }}">
<i class="bi bi-speedometer2"></i>
<span>الرئيسية</span>
</a>
<a href="{{ route('admin.parking-lots.index') }}"
class="mob-nav-item {{ request()->routeIs('admin.parking-lots.*') ? 'active' : '' }}">
<i class="bi bi-buildings"></i>
<span>المواقف</span>
</a>
<a href="{{ route('admin.bookings.active') }}"
class="mob-nav-item {{ request()->routeIs('admin.bookings.*') ? 'active' : '' }}">
<i class="bi bi-calendar-check"></i>
<span>الحجوزات</span>
</a>
<a href="{{ route('operator.dashboard') }}"
class="mob-nav-item {{ request()->routeIs('operator.*') ? 'active' : '' }}">
<i class="bi bi-person-badge"></i>
<span>التشغيل</span>
</a>
</nav>
<script>
// Mobile sidebar toggle
const toggle = document.getElementById('sidebarToggle');
const sidebar = document.getElementById('appSidebar');
const overlay = document.getElementById('sidebarOverlay');
function openSidebar() {
sidebar.classList.add('is-open');
overlay.classList.add('is-open');
document.body.style.overflow = 'hidden';
}
function closeSidebar() {
sidebar.classList.remove('is-open');
overlay.classList.remove('is-open');
document.body.style.overflow = '';
}
toggle?.addEventListener('click', openSidebar);
overlay.addEventListener('click', closeSidebar);
</script>
@stack('scripts')
</body>
</html>

View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title', 'دمشق باركينغ')</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
@vite(['resources/css/app.scss', 'resources/js/app.js'])
</head>
<body class="auth-page">
<div class="auth-box">
{{-- Brand --}}
<div class="auth-brand">
<div class="brand-icon">
<i class="bi bi-p-square-fill"></i>
</div>
<h1>دمشق باركينغ</h1>
<p>نظام إدارة مواقف السيارات</p>
</div>
{{-- Card --}}
<div class="auth-card">
@yield('content')
</div>
{{-- Back link --}}
<div class="text-center mt-4">
<a href="{{ route('parking.index') }}"
class="text-sm"
style="color:#64748b;text-decoration:none;">
<i class="bi bi-arrow-left me-1"></i>
العودة إلى الموقع الرئيسي
</a>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'دمشق باركينغ')</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
@vite(['resources/css/app.scss', 'resources/js/app.js'])
</head>
<body style="background:#f1f5f9;font-family:'Cairo',sans-serif;">
{{-- ══ HEADER ══════════════════════════════════════════════════════════════ --}}
<header class="public-header">
<div class="container">
<div class="d-flex align-items-center justify-content-between">
{{-- Logo --}}
<a href="{{ route('parking.index') }}" class="d-flex align-items-center gap-3 text-decoration-none">
<div style="width:40px;height:40px;background:#6366f1;border-radius:.625rem;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="bi bi-p-square-fill text-white" style="font-size:1.25rem;"></i>
</div>
<div>
<div class="fw-800" style="color:#f8fafc;font-size:1.05rem;line-height:1.2;">دمشق باركينغ</div>
<div style="color:#94a3b8;font-size:.72rem;">مواقف السيارات في دمشق</div>
</div>
</a>
{{-- User Dropdown --}}
@include('partials.user-dropdown')
</div>
</div>
</header>
{{-- ══ CONTENT ═════════════════════════════════════════════════════════════ --}}
<main class="container py-4" style="max-width:820px;">
{{-- Flash messages --}}
@if(session('success'))
<div class="alert alert-success d-flex align-items-center gap-2 border-0 rounded-3 py-2 mb-4">
<i class="bi bi-check-circle-fill flex-shrink-0"></i>
<span>{{ session('success') }}</span>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger d-flex align-items-center gap-2 border-0 rounded-3 py-2 mb-4">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0"></i>
<span>{{ session('error') }}</span>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
</div>
@endif
@yield('content')
</main>
</body>
</html>

View File

@ -0,0 +1,925 @@
@extends('layouts.admin')
@section('title', 'لوحة المشغّل — دمشق باركينغ')
@section('page-title', 'لوحة المشغّل')
@push('styles')
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css">
<style>
.leaflet-container img { max-width:none !important; box-shadow:none !important; }
.leaflet-container { direction:ltr; }
/* ── Lot picker cards ─────────────────────────────────────────────── */
.lot-picker-card {
background:#fff;
border:2px solid #e2e8f0;
border-radius:.875rem;
padding:1.125rem 1.25rem;
cursor:pointer;
transition:border-color .18s, box-shadow .18s, transform .18s;
position:relative;
overflow:hidden;
}
.lot-picker-card::before {
content:'';
position:absolute;
inset-inline-start:0; top:0; bottom:0;
width:4px;
background:#6366f1;
opacity:0;
transition:opacity .18s;
border-radius:2px 0 0 2px;
}
.lot-picker-card:hover {
border-color:#a5b4fc;
box-shadow:0 4px 20px rgba(99,102,241,.12);
transform:translateY(-2px);
}
.lot-picker-card:hover::before { opacity:1; }
.lot-picker-card.highlighted { border-color:#6366f1; box-shadow:0 0 0 3px rgba(99,102,241,.15); }
.lot-picker-card.highlighted::before { opacity:1; }
.lot-picker-card .select-btn {
display:none;
width:100%;
margin-top:.875rem;
padding:.45rem;
background:#6366f1;
color:#fff;
border:none;
border-radius:.5rem;
font-family:'Cairo',sans-serif;
font-weight:700;
font-size:.875rem;
cursor:pointer;
transition:background .15s;
}
.lot-picker-card:hover .select-btn,
.lot-picker-card.highlighted .select-btn { display:block; }
.lot-picker-card .select-btn:hover { background:#4f46e5; }
/* Occupancy bar */
.occ-bar { height:6px; background:#e2e8f0; border-radius:3px; overflow:hidden; margin:.5rem 0 .625rem; }
.occ-bar-fill { height:100%; border-radius:3px; transition:width .4s; }
/* ── Operator panel header ───────────────────────────────────────── */
.op-header {
background:linear-gradient(135deg,#1e1b4b 0%,#312e81 50%,#3730a3 100%);
border-radius:.875rem;
padding:1.25rem 1.5rem;
color:#fff;
margin-bottom:1.5rem;
}
/* ── Tabs ─────────────────────────────────────────────────────────── */
.op-tabs { display:flex; gap:.375rem; margin-bottom:1.25rem; border-bottom:2px solid #e2e8f0; padding-bottom:0; }
.op-tab {
padding:.55rem 1.125rem;
border:none;
border-bottom:3px solid transparent;
margin-bottom:-2px;
background:none;
font-family:'Cairo',sans-serif;
font-weight:600;
font-size:.875rem;
color:#64748b;
cursor:pointer;
border-radius:.5rem .5rem 0 0;
transition:color .15s, border-color .15s;
display:flex;
align-items:center;
gap:.4rem;
}
.op-tab:hover { color:#0f172a; }
.op-tab.active { color:#6366f1; border-bottom-color:#6366f1; }
.op-tab .badge { font-size:.68rem; padding:.2em .5em; }
/* ── Receipt modal ────────────────────────────────────────────────── */
.fee-row { display:flex; justify-content:space-between; padding:.35rem 0; border-bottom:1px dashed #f1f5f9; font-size:.875rem; }
.fee-row:last-child { border-bottom:none; }
.fee-total { display:flex; justify-content:space-between; padding:.625rem 0; border-top:2px solid #e2e8f0; font-weight:800; font-size:1.05rem; color:#0f172a; margin-top:.25rem; }
.pay-method { border:2px solid #e2e8f0; border-radius:.625rem; padding:.875rem 1rem; cursor:pointer; transition:border-color .15s,background .15s; text-align:center; }
.pay-method:hover { border-color:#a5b4fc; }
.pay-method.selected { border-color:#6366f1; background:#f0f4ff; }
.pay-method i { font-size:1.5rem; display:block; margin-bottom:.25rem; }
</style>
@endpush
@section('content')
{{-- ══════════════════════════════════════════════════════════════════════
LOT PICKER shown when no lot is selected
══════════════════════════════════════════════════════════════════════ --}}
@if(!$selectedLot)
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div>
<h2 class="fw-800 mb-0" style="font-size:1.1rem;color:#0f172a;">اختر موقف السيارات</h2>
<p class="text-sm mb-0" style="color:#64748b;">ابحث عن موقفك وابدأ إدارة السيارات</p>
</div>
<span class="badge badge-soft-primary">{{ $parkingLots->count() }} موقف متاح</span>
</div>
{{-- Search bar --}}
<div class="input-group mb-4" style="max-width:480px;">
<span class="input-group-text" style="background:#fff;border-color:#e2e8f0;">
<i class="bi bi-search" style="color:#94a3b8;"></i>
</span>
<input type="text" id="lotSearch" class="form-control" style="border-color:#e2e8f0;"
placeholder="ابحث باسم الموقف أو العنوان...">
<button id="clearSearch" class="btn" style="display:none;background:#f1f5f9;border:1px solid #e2e8f0;color:#475569;">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="row g-3">
{{-- ── Cards column ──────────────────────────────────────────────── --}}
<div class="col-lg-6 col-xl-5" id="cardsCol">
<p id="searchMeta" class="text-xs mb-2" style="color:#94a3b8;display:none;"></p>
<div id="cardGrid" class="row g-3">
@forelse($parkingLots as $lot)
@php
$pct = $lot['total'] > 0 ? round($lot['occupied'] / $lot['total'] * 100) : 0;
$avail = $lot['avail'];
if ($avail === 0) { $badgeCls = 'badge-soft-danger'; $badgeTxt = 'ممتلئ'; $barCol = '#ef4444'; }
elseif ($avail < $lot['total'] * 0.2) { $badgeCls = 'badge-soft-warning'; $badgeTxt = $avail.' محدود'; $barCol = '#f59e0b'; }
else { $badgeCls = 'badge-soft-success'; $badgeTxt = $avail.' متاح'; $barCol = '#10b981'; }
@endphp
<div class="col-12 lot-card-wrap"
data-name="{{ mb_strtolower($lot['name']) }}"
data-address="{{ mb_strtolower($lot['address']) }}">
<div class="lot-picker-card" id="lcard-{{ $lot['id'] }}"
onclick="selectLot({{ $lot['id'] }})">
<div class="d-flex align-items-start justify-content-between gap-2">
<div>
<div class="fw-700" style="color:#0f172a;font-size:.95rem;line-height:1.3;">
{{ $lot['name'] }}
</div>
<div class="text-xs mt-1" style="color:#94a3b8;">
<i class="bi bi-geo-alt me-1"></i>{{ $lot['address'] }}
</div>
</div>
<span class="badge {{ $badgeCls }} flex-shrink-0" style="font-size:.72rem;">{{ $badgeTxt }}</span>
</div>
<div class="occ-bar mt-2">
<div class="occ-bar-fill" style="width:{{ $pct }}%;background:{{ $barCol }};"></div>
</div>
<div class="d-flex gap-3 text-xs" style="color:#64748b;">
<span><i class="bi bi-car-front me-1"></i>{{ $lot['total'] }} مكان</span>
<span><i class="bi bi-currency-exchange me-1"></i>{{ number_format($lot['price']) }}/ساعة</span>
<span><i class="bi bi-clock me-1"></i>{{ $lot['hours'] }}</span>
</div>
<button class="select-btn" onclick="event.stopPropagation();selectLot({{ $lot['id'] }})">
<i class="bi bi-check2-circle me-2"></i>اختر هذا الموقف
</button>
</div>
</div>
@empty
<div class="col-12 text-center py-5" style="color:#94a3b8;">
<i class="bi bi-buildings d-block mb-2" style="font-size:2.5rem;opacity:.3;"></i>
<span class="text-sm">لا توجد مواقف نشطة</span>
</div>
@endforelse
<div id="noResults" class="col-12 text-center py-4" style="color:#94a3b8;display:none;">
<i class="bi bi-search d-block mb-2" style="font-size:2rem;opacity:.3;"></i>
<span class="text-sm">لا توجد نتائج مطابقة</span>
</div>
</div>
</div>
{{-- ── Map column ─────────────────────────────────────────────────── --}}
<div class="col-lg-6 col-xl-7" id="mapCol">
<div class="card" style="height:520px;overflow:hidden;">
<div class="card-header py-2 d-flex align-items-center justify-content-between">
<span class="fw-700 text-sm" style="color:#0f172a;">
<i class="bi bi-map me-1" style="color:#6366f1;"></i>خريطة المواقف
</span>
<span class="badge badge-soft-info text-xs">اضغط على الموقف للتحديد</span>
</div>
<div id="opMap" style="height:calc(100% - 49px);"></div>
</div>
</div>
</div>
{{-- Mobile tab toggle --}}
<div class="d-flex d-lg-none gap-2 mt-3">
<button class="btn btn-sm fw-600 flex-fill" id="mTabCards" onclick="mobileTab('cards')"
style="background:#6366f1;color:#fff;border:none;font-family:'Cairo',sans-serif;">
<i class="bi bi-grid me-1"></i>البطاقات
</button>
<button class="btn btn-sm fw-600 flex-fill" id="mTabMap" onclick="mobileTab('map')"
style="background:#f1f5f9;color:#475569;border:none;font-family:'Cairo',sans-serif;">
<i class="bi bi-map me-1"></i>الخريطة
</button>
</div>
{{-- ══════════════════════════════════════════════════════════════════════
OPERATOR PANEL shown after lot is selected
══════════════════════════════════════════════════════════════════════ --}}
@else
@php
$pct = $selectedLot->usage_percentage;
$barColor = $pct >= 90 ? '#ef4444' : ($pct >= 60 ? '#f59e0b' : '#10b981');
@endphp
{{-- ── Selected lot header ─────────────────────────────────────────── --}}
<div class="op-header d-flex align-items-center justify-content-between flex-wrap gap-3">
<div class="d-flex align-items-center gap-3">
<div style="width:48px;height:48px;background:rgba(255,255,255,.15);border-radius:.75rem;display:flex;align-items:center;justify-content:center;font-size:1.3rem;flex-shrink:0;">
<i class="bi bi-buildings"></i>
</div>
<div>
<div class="fw-800" style="font-size:1.1rem;line-height:1.3;">{{ $selectedLot->name }}</div>
<div style="font-size:.78rem;opacity:.72;"><i class="bi bi-geo-alt me-1"></i>{{ $selectedLot->address }}</div>
</div>
</div>
<div class="d-flex align-items-center gap-3 flex-wrap">
{{-- Live stats --}}
<div class="d-flex gap-3 text-center">
<div>
<div class="fw-800" style="font-size:1.3rem;line-height:1;color:#6ee7b7;">{{ $selectedLot->available_spaces }}</div>
<div style="font-size:.7rem;opacity:.7;">متاح</div>
</div>
<div style="width:1px;background:rgba(255,255,255,.2);"></div>
<div>
<div class="fw-800" style="font-size:1.3rem;line-height:1;color:#fcd34d;">{{ $selectedLot->occupied_spaces }}</div>
<div style="font-size:.7rem;opacity:.7;">مشغول</div>
</div>
<div style="width:1px;background:rgba(255,255,255,.2);"></div>
<div>
<div class="fw-800" style="font-size:1.3rem;line-height:1;color:#e2e8f0;">{{ $selectedLot->total_capacity }}</div>
<div style="font-size:.7rem;opacity:.7;">إجمالي</div>
</div>
</div>
{{-- Capacity bar (inline) --}}
<div style="min-width:120px;">
<div class="d-flex justify-content-between mb-1" style="font-size:.7rem;opacity:.75;">
<span>الإشغال</span><span>{{ round($pct) }}%</span>
</div>
<div style="height:6px;background:rgba(255,255,255,.2);border-radius:3px;overflow:hidden;">
<div style="height:100%;width:{{ $pct }}%;background:{{ $barColor }};border-radius:3px;transition:width .5s;"></div>
</div>
</div>
{{-- Change lot --}}
<a href="{{ route('operator.dashboard') }}"
class="btn btn-sm fw-600"
style="background:rgba(255,255,255,.15);color:#fff;border:1px solid rgba(255,255,255,.3);border-radius:.5rem;font-family:'Cairo',sans-serif;">
<i class="bi bi-arrow-repeat me-1"></i>تغيير الموقف
</a>
</div>
</div>
{{-- Auto-refresh countdown --}}
<div class="d-flex justify-content-end mb-2">
<span class="badge badge-soft-secondary text-xs" id="refresh-badge"></span>
</div>
{{-- ── Tabs ───────────────────────────────────────────────────────────── --}}
<div class="op-tabs">
<button class="op-tab active" onclick="switchTab('active')" id="tab-active">
<i class="bi bi-car-front"></i>
السيارات النشطة
<span class="badge badge-soft-warning">{{ $activeCars->count() }}</span>
</button>
<button class="op-tab" onclick="switchTab('checkin')" id="tab-checkin">
<i class="bi bi-box-arrow-in-left"></i>
إدخال يدوي
</button>
<button class="op-tab" onclick="switchTab('reservations')" id="tab-reservations">
<i class="bi bi-calendar-check"></i>
الحجوزات المسبقة
<span class="badge badge-soft-primary">{{ $reservations->count() }}</span>
</button>
</div>
{{-- ─────────────────────────────────────────────────────────────────────
TAB 1: Active walk-in cars
──────────────────────────────────────────────────────────────────────── --}}
<div id="panel-active">
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between py-2">
<span class="fw-700 text-sm" style="color:#0f172a;">
<i class="bi bi-car-front me-1" style="color:#6366f1;"></i>السيارات داخل الموقف
</span>
</div>
@if($activeCars->isEmpty())
<div class="text-center py-5" style="color:#94a3b8;">
<i class="bi bi-car-front d-block mb-2" style="font-size:2.5rem;opacity:.3;"></i>
<p class="fw-600 mb-1" style="color:#64748b;">لا توجد سيارات داخل الموقف</p>
<button class="btn btn-sm fw-600 mt-1"
style="background:#6366f1;color:#fff;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;"
onclick="switchTab('checkin')">
<i class="bi bi-plus-circle me-1"></i>إدخال سيارة جديدة
</button>
</div>
@else
<div class="table-responsive">
<table class="app-table w-100">
<thead>
<tr>
<th>رقم اللوحة</th>
<th>السائق</th>
<th>وقت الدخول</th>
<th>المدة</th>
<th>المتبقي</th>
<th class="text-center">إجراء</th>
</tr>
</thead>
<tbody>
@foreach($activeCars as $car)
@php
$elapsedMin = $car->start_time->diffInMinutes(now());
$remainMins = now()->diffInMinutes($car->end_time, false);
$isOverdue = $remainMins < 0;
@endphp
<tr id="row-{{ $car->id }}">
<td>
<span class="fw-800" style="font-family:monospace;font-size:1rem;color:#0f172a;letter-spacing:.03em;">
{{ $car->vehicle_plate ?? '—' }}
</span>
</td>
<td class="text-sm" style="color:#475569;">
{{ $car->customer_name ?? 'غير محدد' }}
@if($car->phone)
<div class="text-xs" style="color:#94a3b8;direction:ltr;text-align:right;">{{ $car->phone }}</div>
@endif
</td>
<td class="text-sm" style="color:#475569;">
{{ $car->start_time->format('H:i') }}
<div class="text-xs" style="color:#94a3b8;">{{ $car->start_time->format('Y/m/d') }}</div>
</td>
<td>
<span class="badge badge-soft-secondary text-xs">
{{ floor($elapsedMin/60) }}س {{ $elapsedMin%60 }}د
</span>
</td>
<td>
@if($isOverdue)
<span class="badge badge-soft-danger text-xs fw-600">
تجاوز {{ floor(abs($remainMins)/60) }}س {{ abs($remainMins)%60 }}د
</span>
@else
<span class="badge badge-soft-warning text-xs fw-600">
{{ floor($remainMins/60) }}س {{ $remainMins%60 }}د
</span>
@endif
</td>
<td class="text-center">
<button onclick="openReceipt({{ $car->id }})"
class="btn btn-sm fw-600"
style="background:rgba(99,102,241,.1);color:#4338ca;border:none;border-radius:.375rem;font-family:'Cairo',sans-serif;padding:.3rem .875rem;">
<i class="bi bi-receipt me-1"></i>خروج وفاتورة
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
{{-- ─────────────────────────────────────────────────────────────────────
TAB 2: Manual check-in form
──────────────────────────────────────────────────────────────────────── --}}
<div id="panel-checkin" style="display:none;">
<div class="card" style="max-width:560px;">
<div class="card-header" style="background:rgba(16,185,129,.05);">
<span class="fw-700 text-sm" style="color:#059669;">
<i class="bi bi-box-arrow-in-left me-1"></i>تسجيل دخول سيارة جديدة
</span>
</div>
<div class="card-body p-4">
<form id="checkInForm">
@csrf
<input type="hidden" name="parking_lot_id" value="{{ $selectedLot->id }}">
<div class="mb-3">
<label class="form-label">رقم اللوحة <span style="color:#ef4444;">*</span></label>
<input type="text" name="vehicle_plate" required
class="form-control fw-700"
style="font-size:1.05rem;letter-spacing:.04em;"
placeholder="مثال: أ ب ج 1234"
autocomplete="off">
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label">اسم السائق</label>
<input type="text" name="customer_name" class="form-control" placeholder="اختياري">
</div>
<div class="col-sm-6">
<label class="form-label">الهاتف</label>
<input type="tel" name="phone" class="form-control" placeholder="اختياري" dir="ltr">
</div>
</div>
<div class="mb-4">
<label class="form-label">المدة المتوقعة <span style="color:#ef4444;">*</span></label>
<div class="row g-2">
@foreach([1,2,3,4,6,8,12,24,48,72] as $h)
<div class="col-4 col-sm-3">
<label class="d-block text-center px-2 py-2 rounded-3"
style="border:2px solid #e2e8f0;cursor:pointer;font-size:.82rem;font-weight:600;color:#475569;transition:all .15s;"
onclick="this.parentElement.parentElement.querySelectorAll('label').forEach(l=>l.style.cssText='border:2px solid #e2e8f0;cursor:pointer;font-size:.82rem;font-weight:600;color:#475569;transition:all .15s;');this.style.cssText='border:2px solid #6366f1;cursor:pointer;font-size:.82rem;font-weight:700;color:#4338ca;background:#f0f4ff;transition:all .15s;'">
<input type="radio" name="duration_hours" value="{{ $h }}" class="d-none" {{ $h==2 ? 'checked' : '' }}>
{{ $h }}{{ $h >= 24 ? ' يوم' : 'س' }}
</label>
</div>
@endforeach
</div>
</div>
<button type="submit" id="checkInBtn"
class="btn w-100 fw-700"
style="background:#10b981;color:#fff;border:none;border-radius:.5rem;padding:.7rem;font-family:'Cairo',sans-serif;font-size:.95rem;">
<i class="bi bi-box-arrow-in-left me-2"></i>تسجيل الدخول
</button>
</form>
</div>
</div>
</div>
{{-- ─────────────────────────────────────────────────────────────────────
TAB 3: Pre-reservations
──────────────────────────────────────────────────────────────────────── --}}
<div id="panel-reservations" style="display:none;">
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between py-2">
<span class="fw-700 text-sm" style="color:#0f172a;">
<i class="bi bi-calendar-check me-1" style="color:#0ea5e9;"></i>حجوزات قادمة
</span>
</div>
@if($reservations->isEmpty())
<div class="text-center py-5" style="color:#94a3b8;">
<i class="bi bi-calendar-x d-block mb-2" style="font-size:2.5rem;opacity:.3;"></i>
<p class="fw-600 mb-0" style="color:#64748b;">لا توجد حجوزات مسبقة لهذا الموقف</p>
</div>
@else
<div class="table-responsive">
<table class="app-table w-100">
<thead>
<tr>
<th>#</th>
<th>العميل</th>
<th>الهاتف</th>
<th>وقت البدء</th>
<th>وقت الانتهاء</th>
<th class="text-center">إجراء</th>
</tr>
</thead>
<tbody>
@foreach($reservations as $res)
<tr id="res-{{ $res->id }}">
<td class="text-sm" style="color:#94a3b8;">{{ $res->id }}</td>
<td class="fw-600 text-sm" style="color:#0f172a;">{{ $res->customer_name ?? '—' }}</td>
<td class="text-sm" style="color:#475569;direction:ltr;text-align:right;">{{ $res->phone ?? '—' }}</td>
<td class="text-sm" style="color:#475569;">
{{ $res->start_time->format('H:i') }}
<div class="text-xs" style="color:#94a3b8;">{{ $res->start_time->format('Y/m/d') }}</div>
</td>
<td class="text-sm" style="color:#475569;">
{{ $res->end_time->format('H:i') }}
<div class="text-xs" style="color:#94a3b8;">{{ $res->end_time->format('Y/m/d') }}</div>
</td>
<td class="text-center">
<button onclick="activateRes({{ $res->id }}, this)"
class="btn btn-sm fw-600"
style="background:rgba(16,185,129,.1);color:#059669;border:none;border-radius:.375rem;font-family:'Cairo',sans-serif;padding:.3rem .875rem;">
<i class="bi bi-door-open me-1"></i>فتح البوابة
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>
@endif {{-- end selectedLot --}}
{{-- ══════════════════════════════════════════════════════════════════════
RECEIPT & PAYMENT MODAL
══════════════════════════════════════════════════════════════════════ --}}
<div class="modal fade" id="receiptModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" style="max-width:520px;">
<div class="modal-content" style="border:none;border-radius:1rem;overflow:hidden;">
<div class="modal-header" style="background:#1e1b4b;color:#fff;border:none;padding:1.125rem 1.5rem;">
<div>
<h5 class="modal-title fw-800 mb-0" style="font-size:1rem;">
<i class="bi bi-receipt me-2"></i>فاتورة الخروج
</h5>
<div id="rcpt-lot" class="text-xs mt-1" style="opacity:.7;"></div>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-4">
{{-- Car info --}}
<div class="d-flex align-items-center gap-3 mb-3 p-3 rounded-3" style="background:#f8fafc;">
<div style="width:46px;height:46px;background:#6366f1;border-radius:.625rem;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i class="bi bi-car-front" style="color:#fff;font-size:1.2rem;"></i>
</div>
<div>
<div class="fw-800" style="font-size:1.1rem;color:#0f172a;letter-spacing:.04em;" id="rcpt-plate"></div>
<div class="text-xs" style="color:#64748b;" id="rcpt-name"></div>
</div>
<div class="me-auto text-center">
<div class="fw-800" style="color:#6366f1;font-size:1.1rem;" id="rcpt-duration"></div>
<div class="text-xs" style="color:#64748b;">مدة الإقامة</div>
</div>
</div>
{{-- Times --}}
<div class="row g-2 mb-3">
<div class="col-6">
<div class="p-2 rounded-3 text-center" style="background:#f0fdf4;">
<div class="text-xs fw-600 mb-1" style="color:#64748b;">وقت الدخول</div>
<div class="fw-700 text-sm" id="rcpt-entry" style="color:#059669;"></div>
</div>
</div>
<div class="col-6">
<div class="p-2 rounded-3 text-center" style="background:#fef2f2;">
<div class="text-xs fw-600 mb-1" style="color:#64748b;">وقت الخروج</div>
<div class="fw-700 text-sm" id="rcpt-exit" style="color:#dc2626;"></div>
</div>
</div>
</div>
{{-- Fee breakdown --}}
<div class="mb-3">
<div class="fw-700 text-sm mb-2" style="color:#0f172a;">تفصيل الأجرة</div>
<div id="rcpt-breakdown" style="background:#f8fafc;border-radius:.625rem;padding:.625rem .875rem;">
<div class="text-center py-2" style="color:#94a3b8;">
<span class="spinner-border spinner-border-sm"></span>
</div>
</div>
<div class="fee-total px-1">
<span>الإجمالي</span>
<span id="rcpt-total" style="color:#6366f1;"></span>
</div>
</div>
{{-- Payment method --}}
<div class="mb-3">
<div class="fw-700 text-sm mb-2" style="color:#0f172a;">طريقة الدفع</div>
<div class="row g-2">
<div class="col-6">
<div class="pay-method selected" id="pm-cash" onclick="selectPayment('cash')">
<i class="bi bi-cash-coin" style="color:#10b981;"></i>
<div class="fw-700 text-sm" style="color:#0f172a;">نقداً</div>
<div class="text-xs" style="color:#64748b;">دفع مباشر</div>
</div>
</div>
<div class="col-6">
<div class="pay-method" id="pm-upload" onclick="selectPayment('upload')">
<i class="bi bi-cloud-upload" style="color:#6366f1;"></i>
<div class="fw-700 text-sm" style="color:#0f172a;">إيصال إلكتروني</div>
<div class="text-xs" style="color:#64748b;">رفع صورة الدفع</div>
</div>
</div>
</div>
{{-- File upload (hidden until upload selected) --}}
<div id="uploadArea" class="mt-3" style="display:none;">
<label class="form-label text-sm">رفع إيصال الدفع <span style="color:#ef4444;">*</span></label>
<input type="file" id="paymentProofFile" accept="image/*,.pdf"
class="form-control form-control-sm">
<div class="text-xs mt-1" style="color:#94a3b8;">JPG / PNG / PDF حد أقصى 4MB</div>
</div>
</div>
</div>
<div class="modal-footer" style="border:none;padding:1rem 1.5rem 1.5rem;">
<button type="button" class="btn 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="confirmPayBtn"
class="btn fw-700 flex-fill"
style="background:#6366f1;color:#fff;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;padding:.65rem;">
<i class="bi bi-check2-circle me-2"></i>تأكيد الدفع وإغلاق البوابة
</button>
</div>
</div>
</div>
</div>
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const lots = {!! $parkingLots->toJson() !!};
const csrf = document.querySelector('meta[name="csrf-token"]').content;
// ── Navigate to lot ──────────────────────────────────────────────────────────
function selectLot(id) { window.location = '/operator/dashboard?lot_id=' + id; }
@if(!$selectedLot)
// ════════════════════════════════════════════════════════════════════════════
// LOT PICKER SCRIPTS
// ════════════════════════════════════════════════════════════════════════════
// ── Map ──────────────────────────────────────────────────────────────────────
const map = L.map('opMap').setView([33.5138, 36.2765], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution:'© OpenStreetMap' }).addTo(map);
const markers = {};
lots.forEach(l => {
const col = l.avail === 0 ? '#ef4444' : (l.avail < l.total * .2 ? '#f59e0b' : '#10b981');
const pct = l.total > 0 ? Math.round(l.occupied / l.total * 100) : 0;
const icon = L.divIcon({
html: `<div style="width:40px;height:40px;background:${col};border:3px solid #fff;border-radius:50% 50% 50% 0;transform:rotate(-45deg);box-shadow:0 3px 12px rgba(0,0,0,.3);"></div>`,
iconSize:[40,40], iconAnchor:[20,40], className:''
});
const m = L.marker([l.lat, l.lng], { icon }).addTo(map);
markers[l.id] = m;
m.bindPopup(`
<div style="font-family:'Cairo',sans-serif;min-width:190px;padding:4px 2px;">
<strong style="font-size:.95rem;color:#0f172a;">${l.name}</strong>
<p style="margin:4px 0 8px;font-size:.78rem;color:#64748b;">${l.address}</p>
<div style="height:5px;background:#e2e8f0;border-radius:3px;overflow:hidden;margin-bottom:8px;">
<div style="height:100%;width:${pct}%;background:${col};border-radius:3px;"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:.78rem;color:#64748b;margin-bottom:8px;">
<span>${l.avail > 0 ? l.avail + ' مكان متاح' : 'ممتلئ'}</span>
<span>${pct}% مشغول</span>
</div>
<button onclick="selectLot(${l.id})"
style="width:100%;padding:7px;background:${col};color:#fff;border:none;border-radius:6px;font-family:'Cairo',sans-serif;font-size:.82rem;font-weight:700;cursor:pointer;">
اختر هذا الموقف
</button>
</div>
`);
m.on('click', () => highlightCard(l.id));
});
if (lots.length) {
const g = L.featureGroup(lots.map(l => L.marker([l.lat, l.lng])));
map.fitBounds(g.getBounds().pad(.15));
}
// ── Card highlight ───────────────────────────────────────────────────────────
let hlId = null;
function highlightCard(id) {
if (hlId) document.getElementById('lcard-' + hlId)?.classList.remove('highlighted');
hlId = id;
const card = document.getElementById('lcard-' + id);
if (card) {
card.classList.add('highlighted');
card.scrollIntoView({ behavior:'smooth', block:'nearest' });
}
const l = lots.find(x => x.id === id);
if (l) map.setView([l.lat, l.lng], 15, { animate:true });
}
// ── Search ───────────────────────────────────────────────────────────────────
const searchEl = document.getElementById('lotSearch');
const clearBtn = document.getElementById('clearSearch');
const noResults = document.getElementById('noResults');
const searchMeta= document.getElementById('searchMeta');
searchEl.addEventListener('input', () => {
const q = searchEl.value.trim().toLowerCase();
clearBtn.style.display = q ? 'block' : 'none';
let vis = 0;
document.querySelectorAll('.lot-card-wrap').forEach(w => {
const match = !q || w.dataset.name.includes(q) || w.dataset.address.includes(q);
w.style.display = match ? '' : 'none';
if (match) vis++;
});
noResults.style.display = vis === 0 ? 'block' : 'none';
searchMeta.style.display = q ? 'block' : 'none';
searchMeta.textContent = `${vis} نتيجة من أصل ${lots.length}`;
});
clearBtn.addEventListener('click', () => {
searchEl.value = ''; searchEl.dispatchEvent(new Event('input')); searchEl.focus();
});
// ── Mobile tab ───────────────────────────────────────────────────────────────
function mobileTab(tab) {
const cc = document.getElementById('cardsCol');
const mc = document.getElementById('mapCol');
const bc = document.getElementById('mTabCards');
const bm = document.getElementById('mTabMap');
if (tab === 'map') {
cc.style.display='none'; mc.style.display='';
bm.style.cssText='background:#6366f1;color:#fff;border:none;font-family:Cairo,sans-serif;';
bc.style.cssText='background:#f1f5f9;color:#475569;border:none;font-family:Cairo,sans-serif;';
setTimeout(() => map.invalidateSize(), 80);
} else {
cc.style.display=''; mc.style.display='';
bc.style.cssText='background:#6366f1;color:#fff;border:none;font-family:Cairo,sans-serif;';
bm.style.cssText='background:#f1f5f9;color:#475569;border:none;font-family:Cairo,sans-serif;';
}
}
if (window.innerWidth < 992) {
document.getElementById('mapCol').style.display = 'none';
}
// Highlight duration radio default
document.querySelectorAll('input[name="duration_hours"]').forEach(r => {
if (r.checked) r.closest('label').style.cssText='border:2px solid #6366f1;cursor:pointer;font-size:.82rem;font-weight:700;color:#4338ca;background:#f0f4ff;transition:all .15s;';
});
@else
// ════════════════════════════════════════════════════════════════════════════
// OPERATOR PANEL SCRIPTS
// ════════════════════════════════════════════════════════════════════════════
// ── Tabs ─────────────────────────────────────────────────────────────────────
function switchTab(name) {
['active','checkin','reservations'].forEach(t => {
document.getElementById('panel-' + t).style.display = t === name ? '' : 'none';
document.getElementById('tab-' + t).classList.toggle('active', t === name);
});
}
// ── Check-in form ─────────────────────────────────────────────────────────────
// Default duration highlight
document.querySelectorAll('input[name="duration_hours"]').forEach(r => {
if (r.checked) r.closest('label').style.cssText='border:2px solid #6366f1;cursor:pointer;font-size:.82rem;font-weight:700;color:#4338ca;background:#f0f4ff;transition:all .15s;';
r.addEventListener('change', () => {
document.querySelectorAll('input[name="duration_hours"]').forEach(x =>
x.closest('label').style.cssText='border:2px solid #e2e8f0;cursor:pointer;font-size:.82rem;font-weight:600;color:#475569;transition:all .15s;'
);
r.closest('label').style.cssText='border:2px solid #6366f1;cursor:pointer;font-size:.82rem;font-weight:700;color:#4338ca;background:#f0f4ff;transition:all .15s;';
});
});
document.getElementById('checkInForm')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('checkInBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>جاري التسجيل...';
try {
const res = await fetch('/operator/check-in', { method:'POST', body: new FormData(e.target) });
const data = await res.json();
if (data.success) {
btn.style.background = '#059669';
btn.innerHTML = '<i class="bi bi-check2-circle me-2"></i>تم التسجيل — يتم التحديث...';
setTimeout(() => location.reload(), 900);
} else {
alert(data.message || 'حدث خطأ'); resetCheckInBtn();
}
} catch { alert('خطأ في الاتصال'); resetCheckInBtn(); }
function resetCheckInBtn() {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-box-arrow-in-left me-2"></i>تسجيل الدخول';
}
});
// ── Activate reservation ──────────────────────────────────────────────────────
async function activateRes(id, btn) {
if (!confirm('تأكيد فتح البوابة لهذا الحجز؟')) return;
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const res = await fetch(`/operator/${id}/activate`, {
method:'POST', headers:{'X-CSRF-TOKEN':csrf,'Content-Type':'application/json'}
});
const data = await res.json();
if (data.success) {
document.getElementById('res-' + id)?.remove();
alert(data.message);
location.reload();
} else { alert(data.message || 'حدث خطأ'); btn.innerHTML=orig; btn.disabled=false; }
} catch { alert('خطأ في الاتصال'); btn.innerHTML=orig; btn.disabled=false; }
}
// ── Receipt modal ─────────────────────────────────────────────────────────────
let receiptModal = null;
let currentBookingId = null;
let selectedPayment = 'cash';
function getModal() {
if (!receiptModal) receiptModal = new bootstrap.Modal(document.getElementById('receiptModal'));
return receiptModal;
}
async function openReceipt(id) {
currentBookingId = id;
selectedPayment = 'cash';
selectPayment('cash');
// Reset breakdown
document.getElementById('rcpt-breakdown').innerHTML =
'<div class="text-center py-2" style="color:#94a3b8;"><span class="spinner-border spinner-border-sm"></span></div>';
document.getElementById('rcpt-plate').textContent = '—';
document.getElementById('rcpt-name').textContent = '—';
document.getElementById('rcpt-duration').textContent = '—';
document.getElementById('rcpt-entry').textContent = '—';
document.getElementById('rcpt-exit').textContent = '—';
document.getElementById('rcpt-total').textContent = '—';
getModal().show();
try {
const res = await fetch(`/operator/${id}/checkout-preview`);
const data = await res.json();
if (!data.success) { alert(data.message); return; }
const d = data.data;
document.getElementById('rcpt-lot').textContent = d.lot_name;
document.getElementById('rcpt-plate').textContent = d.plate || '—';
document.getElementById('rcpt-name').textContent = d.customer_name || 'غير محدد';
document.getElementById('rcpt-duration').textContent = d.duration_label;
document.getElementById('rcpt-entry').textContent = d.entry_time;
document.getElementById('rcpt-exit').textContent = d.exit_time;
document.getElementById('rcpt-total').textContent = Number(d.total_fee).toLocaleString('ar-SA') + ' ل.س';
// Breakdown table
const rows = d.fee_details.map(r => `
<div class="fee-row">
<span>${r.day} <small style="color:#94a3b8;">${r.date}</small></span>
<span style="color:#64748b;">${r.hours}س × ${Number(r.rate).toLocaleString('ar-SA')}</span>
<span class="fw-600" style="color:#0f172a;">${Number(r.subtotal).toLocaleString('ar-SA')} ل.س</span>
</div>`).join('');
document.getElementById('rcpt-breakdown').innerHTML = rows || '<p class="text-xs text-center" style="color:#94a3b8;">لا تفاصيل</p>';
} catch { alert('تعذّر تحميل بيانات الفاتورة'); }
}
// Payment method toggle
function selectPayment(method) {
selectedPayment = method;
document.getElementById('pm-cash').classList.toggle('selected', method === 'cash');
document.getElementById('pm-upload').classList.toggle('selected', method === 'upload');
document.getElementById('uploadArea').style.display = method === 'upload' ? 'block' : 'none';
}
// Confirm payment
document.getElementById('confirmPayBtn').addEventListener('click', async () => {
if (!currentBookingId) return;
if (selectedPayment === 'upload' && !document.getElementById('paymentProofFile').files.length) {
alert('يرجى رفع إيصال الدفع'); return;
}
const btn = document.getElementById('confirmPayBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>جاري المعالجة...';
const fd = new FormData();
fd.append('_method', 'POST');
fd.append('payment_method', selectedPayment);
if (selectedPayment === 'upload') {
fd.append('payment_proof', document.getElementById('paymentProofFile').files[0]);
}
try {
const res = await fetch(`/operator/${currentBookingId}/payment`, {
method:'POST',
headers:{'X-CSRF-TOKEN':csrf},
body: fd
});
const data = await res.json();
if (data.success) {
getModal().hide();
document.getElementById('row-' + currentBookingId)?.remove();
const row = document.getElementById('row-' + currentBookingId);
if (row) { row.style.transition='opacity .4s'; row.style.opacity='0'; setTimeout(()=>row.remove(),400); }
setTimeout(() => location.reload(), 600);
} else {
alert(data.message || 'حدث خطأ');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check2-circle me-2"></i>تأكيد الدفع وإغلاق البوابة';
}
} catch {
alert('خطأ في الاتصال');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check2-circle me-2"></i>تأكيد الدفع وإغلاق البوابة';
}
});
// ── Auto-refresh countdown ────────────────────────────────────────────────────
let t = 30;
const badge = document.getElementById('refresh-badge');
setInterval(() => {
t--;
if (badge) badge.textContent = `تحديث تلقائي بعد ${t}ث`;
if (t <= 0) location.reload();
}, 1000);
@endif
</script>
@endpush
@endsection

View File

@ -0,0 +1,126 @@
@auth
{{-- ── Logged-in: profile avatar + dropdown ──────────────────────────────── --}}
<div class="dropdown">
<button type="button"
class="btn btn-sm d-flex align-items-center gap-2"
data-bs-toggle="dropdown"
aria-expanded="false"
style="background:rgba(255,255,255,.1);color:#f8fafc;border:1px solid rgba(255,255,255,.18);border-radius:.625rem;padding:.35rem .75rem;font-family:'Cairo',sans-serif;">
{{-- Avatar circle --}}
<div style="width:32px;height:32px;background:#6366f1;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:.9rem;flex-shrink:0;color:#fff;border:2px solid rgba(255,255,255,.25);">
{{ mb_substr(auth()->user()->name, 0, 1) }}
</div>
<span class="d-none d-sm-inline fw-600" style="font-size:.875rem;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ auth()->user()->name }}
</span>
<i class="bi bi-chevron-down" style="font-size:.65rem;opacity:.7;"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end"
style="min-width:230px;border:1px solid #e2e8f0;box-shadow:0 8px 30px rgba(0,0,0,.12);border-radius:.75rem;padding:.5rem;font-family:'Cairo',sans-serif;">
{{-- User info header --}}
<li>
<div class="d-flex align-items-center gap-3 px-2 py-2">
<div style="width:42px;height:42px;background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:1rem;color:#fff;flex-shrink:0;">
{{ mb_substr(auth()->user()->name, 0, 1) }}
</div>
<div style="min-width:0;">
<div class="fw-700" style="color:#0f172a;font-size:.9rem;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ auth()->user()->name }}
</div>
<div style="color:#64748b;font-size:.75rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ auth()->user()->email }}
</div>
{{-- Role badge --}}
@php
$role = auth()->user()->role;
$roleLabel = match($role) {
'admin' => 'مدير النظام',
'operator' => 'مشغّل',
default => 'مستخدم',
};
$roleColor = match($role) {
'admin' => 'background:rgba(239,68,68,.1);color:#b91c1c;',
'operator' => 'background:rgba(245,158,11,.1);color:#92400e;',
default => 'background:rgba(99,102,241,.1);color:#4338ca;',
};
@endphp
<span class="badge mt-1" style="{{ $roleColor }}font-size:.68rem;padding:.2em .55em;">{{ $roleLabel }}</span>
</div>
</div>
</li>
<li><hr class="dropdown-divider my-1" style="border-color:#f1f5f9;"></li>
{{-- My profile --}}
<li>
<a href="{{ route('profile.show') }}"
class="dropdown-item d-flex align-items-center gap-2 rounded-2"
style="color:#374151;font-size:.875rem;padding:.5rem .75rem;">
<i class="bi bi-person-circle" style="color:#6366f1;font-size:1rem;width:18px;text-align:center;"></i>
معلوماتي
</a>
</li>
{{-- My reservations --}}
<li>
<a href="{{ route('user.dashboard') }}"
class="dropdown-item d-flex align-items-center gap-2 rounded-2"
style="color:#374151;font-size:.875rem;padding:.5rem .75rem;">
<i class="bi bi-calendar3" style="color:#0ea5e9;font-size:1rem;width:18px;text-align:center;"></i>
حجوزاتي
</a>
</li>
{{-- Operator panel (operators & admins) --}}
@if(in_array(auth()->user()->role, ['operator', 'admin']))
<li>
<a href="{{ route('operator.dashboard') }}"
class="dropdown-item d-flex align-items-center gap-2 rounded-2"
style="color:#374151;font-size:.875rem;padding:.5rem .75rem;">
<i class="bi bi-person-badge" style="color:#f59e0b;font-size:1rem;width:18px;text-align:center;"></i>
لوحة المشغّل
</a>
</li>
@endif
{{-- Admin panel (admins only) --}}
@if(auth()->user()->role === 'admin')
<li>
<a href="{{ route('admin.dashboard') }}"
class="dropdown-item d-flex align-items-center gap-2 rounded-2"
style="color:#374151;font-size:.875rem;padding:.5rem .75rem;">
<i class="bi bi-speedometer2" style="color:#10b981;font-size:1rem;width:18px;text-align:center;"></i>
لوحة الإدارة
</a>
</li>
@endif
<li><hr class="dropdown-divider my-1" style="border-color:#f1f5f9;"></li>
{{-- Sign out --}}
<li>
<form method="POST" action="{{ route('logout') }}" class="m-0">
@csrf
<button type="submit"
class="dropdown-item d-flex align-items-center gap-2 rounded-2"
style="color:#ef4444;font-size:.875rem;padding:.5rem .75rem;width:100%;background:none;border:none;cursor:pointer;font-family:'Cairo',sans-serif;">
<i class="bi bi-box-arrow-left" style="font-size:1rem;width:18px;text-align:center;"></i>
تسجيل الخروج
</button>
</form>
</li>
</ul>
</div>
@else
{{-- ── Guest: login button ──────────────────────────────────────────────── --}}
<a href="{{ route('login') }}"
class="btn btn-sm fw-600"
style="background:#6366f1;color:#fff;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;">
<i class="bi bi-box-arrow-in-left me-1"></i>
<span class="d-none d-sm-inline">تسجيل الدخول</span>
</a>
@endauth

View File

@ -0,0 +1,116 @@
@extends('layouts.user')
@section('title', 'حجوزاتي — دمشق باركينغ')
@section('content')
{{-- Page header --}}
<div class="d-flex align-items-center gap-3 mb-4">
<a href="{{ route('parking.index') }}"
class="btn btn-sm"
style="background:#f1f5f9;color:#475569;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;">
<i class="bi bi-arrow-right me-1"></i>العودة
</a>
<div>
<h1 class="fw-800 mb-0" style="font-size:1.25rem;color:#0f172a;">حجوزاتي</h1>
<p class="text-sm mb-0" style="color:#64748b;">سجل حجوزاتك في مواقف السيارات</p>
</div>
</div>
{{-- ── Stats row ──────────────────────────────────────────────────────────── --}}
<div class="row g-3 mb-4">
@php
$statCards = [
['label' => 'إجمالي الحجوزات', 'value' => $stats['total'], 'icon' => 'bi-calendar3', 'color' => '#6366f1', 'bg' => 'rgba(99,102,241,.1)'],
['label' => 'نشطة', 'value' => $stats['active'], 'icon' => 'bi-clock-history', 'color' => '#f59e0b', 'bg' => 'rgba(245,158,11,.1)'],
['label' => 'مكتملة', 'value' => $stats['completed'], 'icon' => 'bi-check-circle', 'color' => '#10b981', 'bg' => 'rgba(16,185,129,.1)'],
['label' => 'ملغاة', 'value' => $stats['cancelled'], 'icon' => 'bi-x-circle', 'color' => '#ef4444', 'bg' => 'rgba(239,68,68,.1)'],
];
@endphp
@foreach($statCards as $s)
<div class="col-6 col-md-3">
<div class="card stat-card">
<div class="card-body p-3 d-flex align-items-center gap-3">
<div class="rounded-3 d-flex align-items-center justify-content-center flex-shrink-0"
style="width:44px;height:44px;background:{{ $s['bg'] }};">
<i class="bi {{ $s['icon'] }}" style="font-size:1.2rem;color:{{ $s['color'] }};"></i>
</div>
<div>
<div class="fw-800" style="font-size:1.4rem;color:{{ $s['color'] }};line-height:1;">{{ $s['value'] }}</div>
<div class="text-xs" style="color:#64748b;">{{ $s['label'] }}</div>
</div>
</div>
</div>
</div>
@endforeach
</div>
{{-- ── Bookings table ──────────────────────────────────────────────────────── --}}
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-700" style="font-size:.9rem;">
<i class="bi bi-list-check me-2" style="color:#6366f1;"></i>
قائمة الحجوزات
</span>
<span class="badge badge-soft-secondary text-xs">{{ $stats['total'] }} حجز</span>
</div>
@if($bookings->isEmpty())
<div class="text-center py-5" style="color:#94a3b8;">
<i class="bi bi-calendar-x d-block mb-2" style="font-size:2.5rem;opacity:.35;"></i>
<p class="fw-600 mb-1" style="color:#64748b;">لا توجد حجوزات بعد</p>
<p class="text-sm mb-3" style="color:#94a3b8;">ابدأ بالبحث عن موقف وحجز مكانك</p>
<a href="{{ route('parking.index') }}"
class="btn btn-sm fw-600"
style="background:#6366f1;color:#fff;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;">
<i class="bi bi-search me-1"></i>تصفح المواقف
</a>
</div>
@else
<div class="table-responsive">
<table class="app-table w-100">
<thead>
<tr>
<th>#</th>
<th>الموقف</th>
<th>تاريخ البدء</th>
<th>تاريخ الانتهاء</th>
<th>الحالة</th>
</tr>
</thead>
<tbody>
@foreach($bookings as $booking)
<tr>
<td class="text-sm" style="color:#94a3b8;">{{ $booking->id }}</td>
<td>
<span class="fw-600" style="color:#0f172a;">
{{ $booking->parkingLot?->name ?? '—' }}
</span>
<div class="text-xs" style="color:#94a3b8;">{{ $booking->parkingLot?->address }}</div>
</td>
<td class="text-sm" style="color:#475569;">
{{ $booking->start_time->format('Y/m/d') }}
<div class="text-xs" style="color:#94a3b8;">{{ $booking->start_time->format('H:i') }}</div>
</td>
<td class="text-sm" style="color:#475569;">
{{ $booking->end_time->format('Y/m/d') }}
<div class="text-xs" style="color:#94a3b8;">{{ $booking->end_time->format('H:i') }}</div>
</td>
<td>
@if($booking->status === 'active')
<span class="badge badge-soft-warning">نشط</span>
@elseif($booking->status === 'completed')
<span class="badge badge-soft-success">مكتمل</span>
@else
<span class="badge badge-soft-danger">ملغي</span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endsection

View File

@ -0,0 +1,142 @@
@extends('layouts.user')
@section('title', 'معلوماتي — دمشق باركينغ')
@section('content')
{{-- Page header --}}
<div class="d-flex align-items-center gap-3 mb-4">
<a href="{{ route('parking.index') }}"
class="btn btn-sm"
style="background:#f1f5f9;color:#475569;border:none;border-radius:.5rem;font-family:'Cairo',sans-serif;">
<i class="bi bi-arrow-right me-1"></i>العودة
</a>
<div>
<h1 class="fw-800 mb-0" style="font-size:1.25rem;color:#0f172a;">معلوماتي</h1>
<p class="text-sm mb-0" style="color:#64748b;">إدارة بيانات حسابك الشخصي</p>
</div>
</div>
<div class="row g-4">
{{-- ── Identity card ──────────────────────────────────────────────────── --}}
<div class="col-12">
<div class="card">
<div class="card-body p-4">
<div class="d-flex align-items-center gap-4 flex-wrap">
{{-- Avatar --}}
<div style="width:72px;height:72px;background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:1.75rem;color:#fff;flex-shrink:0;">
{{ mb_substr($user->name, 0, 1) }}
</div>
<div>
<h2 class="fw-800 mb-1" style="font-size:1.15rem;color:#0f172a;">{{ $user->name }}</h2>
<p class="mb-1 text-sm" style="color:#64748b;">{{ $user->email }}</p>
@php
$roleLabel = match($user->role) {
'admin' => 'مدير النظام',
'operator' => 'مشغّل',
default => 'مستخدم',
};
$roleStyle = match($user->role) {
'admin' => 'background:rgba(239,68,68,.1);color:#b91c1c;',
'operator' => 'background:rgba(245,158,11,.1);color:#92400e;',
default => 'background:rgba(99,102,241,.1);color:#4338ca;',
};
@endphp
<span class="badge" style="{{ $roleStyle }}font-size:.78rem;padding:.3em .75em;">{{ $roleLabel }}</span>
<span class="text-xs ms-2" style="color:#94a3b8;">
عضو منذ {{ $user->created_at->translatedFormat('F Y') }}
</span>
</div>
</div>
</div>
</div>
</div>
{{-- ── Edit name ───────────────────────────────────────────────────────── --}}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<span class="fw-700" style="font-size:.9rem;">
<i class="bi bi-person me-2" style="color:#6366f1;"></i>تعديل الاسم
</span>
</div>
<div class="card-body p-4">
@if($errors->updateName->any())
<div class="alert alert-danger border-0 rounded-3 py-2 mb-3 text-sm">
{{ $errors->updateName->first() }}
</div>
@endif
<form method="POST" action="{{ route('profile.update') }}">
@csrf
@method('PATCH')
<div class="mb-3">
<label class="form-label">الاسم الكامل</label>
<input type="text" name="name"
class="form-control"
value="{{ old('name', $user->name) }}"
placeholder="أدخل اسمك الكامل">
</div>
<div class="mb-3">
<label class="form-label">البريد الإلكتروني</label>
<input type="email"
class="form-control"
value="{{ $user->email }}"
disabled
style="background:#f8fafc;color:#94a3b8;">
<div class="text-xs mt-1" style="color:#94a3b8;">لا يمكن تغيير البريد الإلكتروني.</div>
</div>
<button type="submit"
class="btn fw-700"
style="background:#6366f1;color:#fff;border:none;font-family:'Cairo',sans-serif;border-radius:.5rem;padding:.5rem 1.5rem;">
<i class="bi bi-check2 me-1"></i>حفظ التغييرات
</button>
</form>
</div>
</div>
</div>
{{-- ── Change password ─────────────────────────────────────────────────── --}}
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<span class="fw-700" style="font-size:.9rem;">
<i class="bi bi-lock me-2" style="color:#f59e0b;"></i>تغيير كلمة السر
</span>
</div>
<div class="card-body p-4">
@if($errors->updatePassword->any())
<div class="alert alert-danger border-0 rounded-3 py-2 mb-3 text-sm">
{{ $errors->updatePassword->first() }}
</div>
@endif
<form method="POST" action="{{ route('profile.password') }}">
@csrf
@method('PATCH')
<div class="mb-3">
<label class="form-label">كلمة السر الحالية</label>
<input type="password" name="current_password"
class="form-control" placeholder="••••••••" dir="ltr">
</div>
<div class="mb-3">
<label class="form-label">كلمة السر الجديدة</label>
<input type="password" name="password"
class="form-control" placeholder="••••••••" dir="ltr">
</div>
<div class="mb-4">
<label class="form-label">تأكيد كلمة السر الجديدة</label>
<input type="password" name="password_confirmation"
class="form-control" placeholder="••••••••" dir="ltr">
</div>
<button type="submit"
class="btn fw-700"
style="background:#f59e0b;color:#fff;border:none;font-family:'Cairo',sans-serif;border-radius:.5rem;padding:.5rem 1.5rem;">
<i class="bi bi-shield-lock me-1"></i>تحديث كلمة السر
</button>
</form>
</div>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ config('app.name', 'دمشق باركينغ') }}</title>
@vite(['resources/css/app.scss', 'resources/js/app.js'])
</head>
<body class="bg-light">
<div class="container-fluid vh-100 d-flex align-items-center justify-content-center">
<div class="row w-100">
<div class="col-lg-6 offset-lg-3">
<div class="card border-0 shadow-lg rounded-5 overflow-hidden">
<div class="card-body p-5 text-center">
<h1 class="display-3 fw-bold text-primary mb-4">
<i class="bi bi-car-front"></i>
دمشق باركينغ
</h1>
<p class="lead text-muted mb-5">نظام مواقف السيارات المتكامل</p>
<div class="row g-4 justify-content-center">
@guest
<div class="col-md-6">
<a href="{{ route('login') }}" class="btn btn-primary btn-lg w-100 py-3 rounded-4 shadow-lg fs-5">
<i class="bi bi-box-arrow-in-left me-2"></i>تسجيل الدخول
</a>
</div>
@if (Route::has('register'))
<div class="col-md-6">
<a href="{{ route('register') }}" class="btn btn-success btn-lg w-100 py-3 rounded-4 shadow-lg fs-5">
<i class="bi bi-person-plus me-2"></i>إنشاء حساب
</a>
</div>
@endif
@else
<div class="col-md-6">
<a href="{{ route('admin.dashboard') }}" class="btn btn-primary btn-lg w-100 py-3 rounded-4 shadow-lg fs-5">
<i class="bi bi-house-door me-2"></i>لوحة الإدارة
</a>
</div>
<div class="col-md-6">
<a href="{{ route('operator.dashboard') }}" class="btn btn-warning btn-lg w-100 py-3 rounded-4 shadow-lg fs-5">
<i class="bi bi-gear me-2"></i>لوحة المشغل
</a>
</div>
@endguest
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

15
routes/api.php Normal file
View File

@ -0,0 +1,15 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
Route::apiResource('parking-lots', \App\Http\Controllers\Api\ParkingLotController::class);
Route::get('parking-lots/search', [\App\Http\Controllers\Api\ParkingLotController::class, 'search']);
Route::get('parking-lots/{parkingLot}/status', [\App\Http\Controllers\Api\ParkingLotController::class, 'status']);
Route::post('car-registries', [\App\Http\Controllers\Api\CarRegistryController::class, 'checkIn']);
Route::put('car-registries/{carRegistry}/exit', [\App\Http\Controllers\Api\CarRegistryController::class, 'checkOut']);
Route::post('bookings', \App\Http\Controllers\Api\BookingController::class);
Route::get('bookings', [\App\Http\Controllers\Api\BookingController::class, 'index']);
});
?>

68
routes/auth.php Normal file
View File

@ -0,0 +1,68 @@
<?php
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use App\Models\User;
Route::middleware('guest')->group(function () {
Route::get('login', function () {
return view('auth.login');
})->name('login');
Route::get('register', function () {
return view('auth.register');
})->name('register');
Route::post('register', function (Request $request) {
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|confirmed|min:8',
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'role' => 'user', // default
]);
Auth::login($user);
return redirect('/dashboard');
})->name('register.action');
Route::post('login', function (Request $request) {
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (Auth::attempt($credentials, $request->boolean('remember'))) {
$request->session()->regenerate();
$role = Auth::user()->role;
$intended = match($role) {
'admin' => '/admin/dashboard',
'operator' => '/operator/dashboard',
default => '/dashboard',
};
return redirect()->intended($intended);
}
return back()->withErrors([
'email' => 'بيانات الدخول غير صحيحة.',
]);
})->name('login.action');
});
Route::middleware('auth')->group(function () {
Route::post('logout', function (Request $request) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
})->name('logout');
});
?>

8
routes/console.php Normal file
View File

@ -0,0 +1,8 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

57
routes/web.php Normal file
View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\ProfileController;
Route::get('/', [App\Http\Controllers\ParkingController::class, 'index'])->name('parking.index');
require __DIR__.'/auth.php';
// User profile & reservations (authenticated users)
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'show'])->name('profile.show');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::patch('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password');
Route::get('/dashboard', [ProfileController::class, 'dashboard'])->name('user.dashboard');
});
// Admin Dashboard routes (protected - uncomment auth middleware for production)
// Route::middleware(['auth:sanctum'])->prefix('admin')->name('admin.')->group(function () {
Route::prefix('admin')->middleware('admin')->name('admin.')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/stats', [DashboardController::class, 'statsJson'])->name('stats');
Route::get('/charts', [DashboardController::class, 'chartsJson'])->name('charts');
// Parking Lots CRUD
Route::get('/parking-lots', [\App\Http\Controllers\Admin\ParkingLotController::class, 'index'])->name('parking-lots.index');
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::put('/parking-lots/{parkingLot}', [\App\Http\Controllers\Admin\ParkingLotController::class, 'update'])->name('parking-lots.update');
Route::post('/parking-lots/{parkingLot}/toggle', [\App\Http\Controllers\Admin\ParkingLotController::class, 'toggleStatus'])->name('parking-lots.toggle');
// Active Bookings
Route::get('/bookings/active', [\App\Http\Controllers\Admin\BookingController::class, 'activeIndex'])->name('bookings.active');
Route::post('/bookings/{booking}/complete', [\App\Http\Controllers\Admin\BookingController::class, 'completeBooking'])->name('bookings.complete');
});
// });
// Operator Dashboard
Route::prefix('operator')->middleware('operator')->name('operator.')->group(function () {
Route::get('/dashboard', [\App\Http\Controllers\Operator\OperatorController::class, 'dashboard'])->name('dashboard');
Route::post('/check-in', [\App\Http\Controllers\Operator\OperatorController::class, 'checkIn'])->name('checkIn');
Route::post('/{booking}/activate', [\App\Http\Controllers\Operator\OperatorController::class, 'activateReservation'])->name('activate');
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');
});
// });
/*
To enable auth:
1. php artisan make:middleware AdminAuth or use existing auth:sanctum
2. Uncomment Route::middleware(['auth:sanctum'])
3. Add super admin user/guard.
*/

4
storage/app/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*
!private/
!public/
!.gitignore

Some files were not shown because too many files have changed in this diff Show More