Pant0x commited on
Commit
cd01269
Β·
0 Parent(s):

Final synced deploy v2 - Fix session and timeouts

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .dockerignore +17 -0
  2. .env +4 -0
  3. .gitattributes +4 -0
  4. .gitignore +8 -0
  5. Apis DOCS/Endpoints.md +103 -0
  6. Apis DOCS/FLUTTER_INTEGRATION_GUIDE.md +162 -0
  7. Apis DOCS/FULL_API_DOCUMENTATION.md +107 -0
  8. Dockerfile +30 -0
  9. Procfile +1 -0
  10. README.md +63 -0
  11. __pycache__/config.cpython-311.pyc +0 -0
  12. app.py +43 -0
  13. assets/emojis.com cctv-camera-emoji.png +3 -0
  14. config.py +17 -0
  15. esp32/gate_controller.ino +175 -0
  16. models/__init__.py +1 -0
  17. models/car_plate_best.pt +3 -0
  18. models/model_ocr.pt +3 -0
  19. requirements.txt +5 -0
  20. routes/__init__.py +1 -0
  21. routes/__pycache__/__init__.cpython-311.pyc +0 -0
  22. routes/__pycache__/admin_routes.cpython-311.pyc +0 -0
  23. routes/__pycache__/auth_routes.cpython-311.pyc +0 -0
  24. routes/__pycache__/camera_routes.cpython-311.pyc +0 -0
  25. routes/__pycache__/security_routes.cpython-311.pyc +0 -0
  26. routes/admin_routes.py +160 -0
  27. routes/auth_routes.py +105 -0
  28. routes/camera_routes.py +67 -0
  29. routes/security_routes.py +159 -0
  30. scratch/test_railway.py +9 -0
  31. services/__init__.py +1 -0
  32. services/__pycache__/__init__.cpython-311.pyc +0 -0
  33. services/__pycache__/api_service.cpython-311.pyc +0 -0
  34. services/__pycache__/camera_service.cpython-311.pyc +0 -0
  35. services/__pycache__/ocr_service.cpython-311.pyc +0 -0
  36. services/api_service.py +415 -0
  37. services/camera_service.py +73 -0
  38. services/ocr_service.py +2 -0
  39. services/supabase_client.py +10 -0
  40. static/assets/cctv_logo.png +3 -0
  41. static/css/style.css +418 -0
  42. templates/admin/dashboard.html +181 -0
  43. templates/admin/garage.html +189 -0
  44. templates/admin/history.html +117 -0
  45. templates/admin/users.html +76 -0
  46. templates/admin/vehicles.html +175 -0
  47. templates/auth/login.html +43 -0
  48. templates/auth/register.html +70 -0
  49. templates/base.html +16 -0
  50. templates/partials/sidebar.html +50 -0
.dockerignore ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ __pycache__
4
+ *.pyc
5
+ *.pyo
6
+ *.pyd
7
+ .db
8
+ .env
9
+ .venv
10
+ venv
11
+ .vscode
12
+ .idea
13
+ .qodo
14
+ scratch
15
+ *.log
16
+ Dockerfile
17
+ .dockerignore
.env ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ FLASK_SECRET_KEY=change-me-in-production-super-secret-key-12345
2
+ BACKEND_API_URL=https://ain-el-aql-backend-production.up.railway.app
3
+ ESP_GATE_URL=http://192.168.1.100/open
4
+ PLATE_COOLDOWN_SECONDS=3
.gitattributes ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ *.pt filter=lfs diff=lfs merge=lfs -text
2
+ *.png filter=lfs diff=lfs merge=lfs -text
3
+ *.jpg filter=lfs diff=lfs merge=lfs -text
4
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ static/uploads/*
5
+ !static/uploads/.gitkeep
6
+ venv/
7
+ .vscode/
8
+ .DS_Store
Apis DOCS/Endpoints.md ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ain El Aql - Endpoints Reference
2
+
3
+ This document catalogs all the REST API endpoints available in the backend. Use this as a reference guide when integrating or building frontend clients (Websites, Flutter Apps, dashboards, etc.).
4
+
5
+ ---
6
+
7
+ ## 🏠 Public & Utility
8
+ | Method | Endpoint | Description |
9
+ |---|---|---|
10
+ | `GET` | `/` | Base API sanity check |
11
+ | `GET` | `/health` | Application health status |
12
+ | `GET` | `/supabase/health` | Supabase connectivity health check |
13
+
14
+ ---
15
+
16
+ ## πŸ” Authentication (`/auth`)
17
+ *Most of these rely on Supabase Auth. They handle user sessions, passwords, and OTP logic.*
18
+
19
+ | Method | Endpoint | Description |
20
+ |---|---|---|
21
+ | `POST` | `/auth/register` | Register a new user |
22
+ | `POST` | `/auth/login` | Login to receive a JWT session token |
23
+ | `POST` | `/auth/forgot-password` | Request a password reset link |
24
+ | `POST` | `/auth/reset-password/resolve-token` | Validate the token from the reset email |
25
+ | `POST` | `/auth/reset-password` | Update password |
26
+ | `GET` | `/auth/reset-password-page` | Web UI for resetting password |
27
+ | `GET` | `/auth/reset-password-bridge` | Mobile deep-link bridge for password reset |
28
+ | `POST` | `/auth/otp/request` | Request an OTP code via Email/Phone |
29
+ | `POST` | `/auth/otp/verify` | Verify the requested OTP code |
30
+
31
+ ---
32
+
33
+ ## πŸ‘€ Profile & Vehicles
34
+ *Requires `Authorization: Bearer <token>`.*
35
+
36
+ | Method | Endpoint | Description |
37
+ |---|---|---|
38
+ | `PUT` | `/profile/update` | Update authenticated user's profile info |
39
+ | `POST` | `/vehicles/add` | Add a new vehicle to user's profile |
40
+ | `GET` | `/vehicles/my` | List authenticated user's vehicles |
41
+ | `PUT` | `/vehicles/{vehicle_id}` | Edit an existing vehicle |
42
+ | `DELETE` | `/vehicles/{vehicle_id}` | Delete/Unlink a vehicle |
43
+ | `POST` | `/vehicles/verify` | Request admin verification for a vehicle |
44
+
45
+ ---
46
+
47
+ ## πŸ”” Notifications
48
+ *Requires `Authorization: Bearer <token>`.*
49
+
50
+ | Method | Endpoint | Description |
51
+ |---|---|---|
52
+ | `POST` | `/notifications/fcm-token` | Register an FCM (Firebase) token for push notifications |
53
+ | `GET` | `/notifications/my` | Get user's personal notification feed |
54
+
55
+ ---
56
+
57
+ ## πŸ€– AI Models & Inference Core
58
+ *The core AI pipeline execution paths.*
59
+
60
+ | Method | Endpoint | Description |
61
+ |---|---|---|
62
+ | `GET` | `/models` | List available AI models |
63
+ | `GET` | `/developer/models` | Diagnostic data for loaded models |
64
+ | `POST` | `/predict` | **Main Pipeline:** Upload image, run OCR, update parking session, return gate decision. |
65
+ | `POST` | `/model/infer` | Remote model execution endpoint (Hugging Face integration) |
66
+ | `POST` | `/developer/predict` | Developer debugging for predictions |
67
+
68
+ ---
69
+
70
+ ## πŸ…ΏοΈ Parking, Events, and History
71
+ *Requires `Authorization: Bearer <token>`.*
72
+ **Role-aware endpoints:** Regular users only see data for their own vehicles. `admin` and `security` roles see all data globally across the entire garage.
73
+
74
+ | Method | Endpoint | Description |
75
+ |---|---|---|
76
+ | `GET` | `/parking/locations` | Get garage capacities, pricing rules, and occupancy snapshot |
77
+ | `GET` | `/parking/occupancy` | Get pure occupancy statistics |
78
+ | `GET` | `/events/entered-cars` | Feed of cars that entered the garage |
79
+ | `GET` | `/events/leaving-cars-within-5-minutes` | Feed of cars currently in their exit grace period |
80
+ | `GET` | `/events/left-cars` | Feed of cars that have exited the garage completely |
81
+ | `GET` | `/events/inside-cars` | Live feed of cars currently physically inside the garage |
82
+ | `GET` | `/parking/history` | Historical log of all parking sessions |
83
+
84
+ ---
85
+
86
+ ## πŸ’³ Payments & Gate Control
87
+
88
+ | Method | Endpoint | Description |
89
+ |---|---|---|
90
+ | `POST` | `/payments/paymob/create` | Initialize a Paymob iframe checkout URL |
91
+ | `POST` | `/payments/paymob/webhook` | Webhook listener for Paymob status updates |
92
+ | `POST` | `/gate/decision` | Evaluate if the physical gate should open based on payment status |
93
+
94
+ ---
95
+
96
+ ## πŸ›‘οΈ Administrative & Security (Strict RBAC)
97
+ *Requires `Authorization: Bearer <token>` with either `admin` or `security` role.*
98
+
99
+ | Method | Endpoint | Description |
100
+ |---|---|---|
101
+ | `GET` | `/events/new-cars` | Real-time polling feed of cars pulling up to cameras (Security Dashboard) |
102
+ | `POST` | `/payments/manual/cash-confirm` | Security action to bypass payment and manually mark session as paid via cash |
103
+ | `POST` | `/admin/maintenance/weekly-refresh` | Internal backend maintenance and cleanup task |
Apis DOCS/FLUTTER_INTEGRATION_GUIDE.md ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Flutter Integration Guide
2
+
3
+ ## Goal
4
+
5
+ Connect Flutter app to Ain El Aql backend to produce a complete parking product:
6
+
7
+ 1. camera-based ANPR detection
8
+ 2. parking session and payment lifecycle
9
+ 3. occupancy and location-aware pricing dashboards
10
+
11
+ ## Responsibility Split
12
+
13
+ ### Backend + AI team (already implemented)
14
+
15
+ 1. AI inference pipeline
16
+ 2. Session/event persistence in Supabase
17
+ 3. Dynamic pricing and capacity logic
18
+ 4. Gate decision logic
19
+ 5. Payment endpoints (Paymob + manual cash)
20
+ 6. Role-aware history/feed APIs
21
+
22
+ ### Flutter team
23
+
24
+ 1. Backend auth screens and token management
25
+ 2. API client layer and request retries
26
+ 3. UI for user/admin/security experiences
27
+ 4. Detection screens and result rendering
28
+ 5. Payment/gate operation UI flow
29
+ 6. Dashboard and reporting screens
30
+
31
+ ## Integration Steps
32
+
33
+ 1. Configure API base URL in Flutter.
34
+ 2. Register/Login through backend auth endpoints and keep access token.
35
+ 3. Send token as `Authorization: Bearer ...` to protected endpoints.
36
+ 4. Use `POST /predict` for main camera action.
37
+ 5. Use `/parking/locations` and `/parking/occupancy` for location cards and dashboard.
38
+ 6. Use feeds/history endpoints for timeline and operational views.
39
+
40
+ ## Current Cloud Base URL
41
+
42
+ - `https://pant0x-ealpr-ocr.hf.space`
43
+
44
+ Sanity checks:
45
+
46
+ 1. `GET /`
47
+ 2. `GET /health`
48
+ 3. `GET /supabase/health`
49
+
50
+ Current deployment mode is `SUPABASE_ENFORCE_AUTH=false` to unblock integration.
51
+ Before production launch, switch to `true` and ensure token headers are sent on protected calls.
52
+
53
+ ## Recommended Flutter Screens
54
+
55
+ 1. Auth (login/register/reset)
56
+ 2. Home/scan screen
57
+ 3. Detection result screen
58
+ 4. Inside/entered/left monitoring screens
59
+ 5. Parking history screen
60
+ 6. Location pricing + occupancy screen
61
+ 7. Payment and gate validation screen
62
+ 8. Admin/security operations panel
63
+
64
+ ## Request/Response Mapping
65
+
66
+ ### `POST /auth/register`
67
+
68
+ Flutter sends:
69
+
70
+ 1. `first_name` (recommended)
71
+ 2. `last_name` (recommended)
72
+ 3. `username` (required)
73
+ 4. `phone` (required)
74
+ 5. `password` (required)
75
+ 6. `email` (optional)
76
+ 7. `nid` (optional)
77
+ 8. optional vehicle payload:
78
+ - `plate_letters_ar`, `plate_numbers_ar`
79
+ - `plate_letters_en`, `plate_numbers_en`
80
+ - `car_model`, `car_color`
81
+
82
+ Flutter reads:
83
+
84
+ 1. `user.role` and `user.is_staff`
85
+ 2. `session.access_token`
86
+ 3. `vehicle` (if provided during registration)
87
+
88
+ ### `POST /auth/login`
89
+
90
+ Flutter sends:
91
+
92
+ 1. `identifier` (username OR phone OR email OR nid)
93
+ 2. `password`
94
+
95
+ Flutter reads:
96
+
97
+ 1. `user.role` and `user.is_staff`
98
+ 2. `session.access_token`
99
+ 3. `session.refresh_token`
100
+
101
+ ### `POST /predict`
102
+
103
+ Flutter sends:
104
+
105
+ 1. image file
106
+ 2. event_type (`entry`/`exit`/`ocr_scan`)
107
+ 3. parking_location (named value like `OPERA`)
108
+
109
+ Flutter reads:
110
+
111
+ 1. `plate_info` / `plate`
112
+ 2. `pricing`
113
+ 3. `payment_status`
114
+ 4. `gate_decision`
115
+ 5. `user_page.split_image_base64`
116
+ 6. `admin_page.annotated_image_base64`
117
+
118
+ ### Occupancy dashboards
119
+
120
+ Use:
121
+
122
+ 1. `GET /parking/locations`
123
+ 2. `GET /parking/occupancy`
124
+
125
+ Render:
126
+
127
+ 1. `garage.display` (X/total)
128
+ 2. each location pricing mode/rates
129
+ 3. each location occupancy display and left capacity
130
+
131
+ ## Role-based UI Recommendations
132
+
133
+ 1. `user`
134
+ - own history/feeds
135
+ - pricing and availability
136
+ 2. `admin`
137
+ - all-user operations, analytics, payment controls
138
+ 3. `security`
139
+ - gate operations and live occupancy monitoring
140
+
141
+ Role source is backend `user.role` from the `profiles` table.
142
+
143
+ ## End-to-End Product Pipeline
144
+
145
+ 1. Driver arrives and selects location in app.
146
+ 2. Camera scan sends image to `/predict` with `event_type=entry`.
147
+ 3. Backend detects plate, creates/updates session, calculates pricing context.
148
+ 4. Driver views status and fee preview.
149
+ 5. On exit, app sends `/predict` with `event_type=exit`.
150
+ 6. Backend validates payment state and returns gate decision.
151
+ 7. Staff can use payment endpoints when required.
152
+ 8. Dashboards reflect occupancy in near-real-time from open sessions.
153
+
154
+ ## Launch Checklist
155
+
156
+ 1. Cloud host `/`, `/health`, and `/supabase/health` pass.
157
+ 2. Supabase tables/buckets configured and secure.
158
+ 3. Auth enforced in production (`SUPABASE_ENFORCE_AUTH=true`).
159
+ 4. Pricing/capacity env values loaded in deployment.
160
+ 5. Flutter role routing and token flow tested.
161
+ 6. Entry/exit/manual payment QA completed.
162
+ 7. Security key rotation completed for exposed secrets.
Apis DOCS/FULL_API_DOCUMENTATION.md ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ain El Aql - Full System Architecture & API Documentation
2
+
3
+ This document serves as the ultimate reference for the Ain El Aql backend, covering everything from the database model to all endpoints, and specifically detailing the **Role-Based Access Control (RBAC)** that governs the User, Admin, and Security roles.
4
+
5
+ ---
6
+
7
+ ## 1. Role-Based Access Control (RBAC)
8
+
9
+ The backend uses a single set of APIs whose behavior changes dynamically based on the authenticated user's role.
10
+
11
+ ### Roles
12
+ 1. **`user`**: Regular customers. They can only see their own registered vehicles, notifications, and parking history.
13
+ 2. **`admin` & `security` (Staff)**: Privileged roles that unlock **God View**. When a staff member calls an endpoint like `/events/entered-cars`, they bypass the user filter and see **all cars** in the entire facility. They also gain access to manual override endpoints like `/payments/manual/cash-confirm`.
14
+ 3. **`barrier`**: A synthetic machine role intended for the camera/gate systems. They authenticate using a static `BARRIER_API_TOKEN` to run OCR scans and query gate decisions without needing a user account.
15
+ 4. **`developer`**: Has access to backend debugging endpoints.
16
+
17
+ ---
18
+
19
+ ## 2. Database Model (Supabase)
20
+
21
+ The relational data is stored in PostgreSQL via Supabase.
22
+
23
+ ### Core Tables
24
+ * **`profiles`**: Links to `auth.users.id`. Stores user identity (`first_name`, `email`, `phone_number`, `national_id`), as well as their `role` (e.g., user, admin).
25
+ * **`vehicles`**: Links to `profiles.id` (`owner_id`). Stores the registration data (Arabic/English plates, car make, color, verification status).
26
+ * **`parking_sessions`**: Tracks the lifecycle of a car parking. Links to `vehicles.id`. A session starts on `entry` and closes on `exit`.
27
+ * **`car_events`**: The immutable ledger of everything that happens. Every entry, exit, or OCR scan creates a row here.
28
+ * **`payment_transactions`**: The ledger for payments, linking Paymob callbacks or Manual Cash confirmations to a parking session.
29
+ * **`notification_device_tokens` & `notification_events`**: Handles FCM (Firebase) push notifications.
30
+
31
+ ### Storage Buckets
32
+ * `car-raw-images`: Stores the full uncropped images from the CCTV cameras.
33
+ * `car-processed-images`: Stores cropped plates and annotated UI images.
34
+
35
+ ---
36
+
37
+ ## 3. Comprehensive API Endpoints Catalog
38
+
39
+ ### 🏠 Public & Utility
40
+ * `GET /` - Base API check.
41
+ * `GET /health` - API health check.
42
+ * `GET /supabase/health` - Checks database connectivity.
43
+
44
+ ### πŸ” Authentication (`/auth`)
45
+ * `POST /auth/register` - Creates a new user in Supabase Auth.
46
+ * `POST /auth/login` - Returns a JWT. If the user is staff, `"is_staff": true` is injected into the response.
47
+ * `POST /auth/otp/request` - Requests an OTP (Email/Phone).
48
+ * `POST /auth/otp/verify` - Verifies an OTP.
49
+ * `POST /auth/forgot-password` - Triggers the Supabase password reset email.
50
+ * `POST /auth/reset-password/resolve-token` - Validates the reset token.
51
+ * `POST /auth/reset-password` - Sets the new password.
52
+ * `GET /auth/reset-password-page` - Web UI fallback for password resets.
53
+ * `GET /auth/reset-password-bridge` - Mobile deep link bridge.
54
+
55
+ ### πŸ‘€ Profile & Vehicles (`/profile`, `/vehicles`)
56
+ * `PUT /profile/update` - Modifies `profiles` table data.
57
+ * `POST /vehicles/add` - Registers a new vehicle to the user.
58
+ * `GET /vehicles/my` - Lists the authenticated user's vehicles.
59
+ * `PUT /vehicles/{vehicle_id}` - Edits a vehicle's details.
60
+ * `DELETE /vehicles/{vehicle_id}` - Unlinks a vehicle.
61
+ * `POST /vehicles/verify` - Submits a vehicle for administrative verification.
62
+
63
+ ### πŸ”” Notifications (`/notifications`)
64
+ * `POST /notifications/fcm-token` - Registers a mobile device token.
65
+ * `GET /notifications/my` - Retrieves the user's unread/read notifications.
66
+
67
+ ### πŸ€– AI Models & Inference Core
68
+ * `GET /models` - Lists available YOLO models.
69
+ * `GET /developer/models` - Internal diagnostic for models.
70
+ * `POST /predict` - **The core pipeline endpoint.** Receives an image, runs YOLO car detection -> YOLO plate detection -> OCR. Records the event in Supabase and returns the gate decision and pricing.
71
+ * `POST /model/infer` - Executed by the Hugging Face space when running in remote inference mode.
72
+ * `POST /developer/predict` - Testing endpoint for developers.
73
+
74
+ ### πŸ…ΏοΈ Parking, Events, and History
75
+ *(Note: All endpoints below return "Own Data" for Users, and "Global Data" for Admin/Security)*
76
+ * `GET /parking/locations` - Returns garage capacities, pricing rules, and current overall occupancy.
77
+ * `GET /parking/occupancy` - Returns pure occupancy counts.
78
+ * `GET /events/entered-cars` - Feed of cars that have successfully entered.
79
+ * `GET /events/leaving-cars-within-5-minutes` - Feed of cars currently in their exit grace period.
80
+ * `GET /events/left-cars` - Feed of closed sessions.
81
+ * `GET /events/inside-cars` - Live view of all cars physically inside the garage right now.
82
+ * `GET /parking/history` - The permanent ledger of all past parking sessions.
83
+
84
+ ### πŸ’³ Payments & Gate Control
85
+ * `POST /payments/paymob/create` - Generates a Paymob iframe URL for credit card checkout.
86
+ * `POST /payments/paymob/webhook` - Secure listener for Paymob status updates.
87
+ * `POST /gate/decision` - Calculates if a specific car is allowed to exit right now based on its payment status.
88
+
89
+ ### πŸ›‘οΈ Exclusive Admin / Security Endpoints
90
+ *(Restricted entirely to the `admin` and `security` roles)*
91
+ * `GET /events/new-cars` - Real-time polling feed of cars directly at the camera right now.
92
+ * `POST /payments/manual/cash-confirm` - Allows a guard to confirm they received cash, bypassing the credit card flow and opening the gate.
93
+ * `POST /admin/maintenance/weekly-refresh` - System cleanup script.
94
+
95
+ ---
96
+
97
+ ## 4. End-to-End Flow Example: The `/predict` Pipeline
98
+
99
+ When a camera sends an image to `POST /predict`:
100
+ 1. **Auth:** Checked against `BARRIER_API_TOKEN` or a user JWT.
101
+ 2. **AI Inference:** The image is passed through 3 AI models (Car -> Plate -> OCR).
102
+ 3. **Matching Logic:** The detected plate string is normalized and searched in the `vehicles` table (matching both Arabic and English variants).
103
+ 4. **Session Logic:**
104
+ * If `event_type=entry`, a new `parking_sessions` row is created.
105
+ * If `event_type=exit`, the pricing engine calculates the fee.
106
+ 5. **Persistence:** Images are uploaded to Supabase Storage, and the `car_events` ledger is updated.
107
+ 6. **Response:** Flutter receives a massive JSON containing the plate text, pricing breakdown, and `gate_decision.allowed = true/false`.
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.11 slim image
2
+ FROM python:3.11-slim
3
+
4
+ # Install system dependencies
5
+ # libglib2.0-0 is often needed for some image processing libraries
6
+ RUN apt-get update && apt-get install -y \
7
+ libglib2.0-0 \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Create a non-root user (UID 1000 is required by Hugging Face)
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ ENV HOME=/home/user \
14
+ PATH=/home/user/.local/bin:$PATH
15
+
16
+ WORKDIR $HOME/app
17
+
18
+ # Copy and install dependencies
19
+ COPY --chown=user requirements.txt .
20
+ RUN pip install --no-cache-dir --user -r requirements.txt
21
+
22
+ # Copy the application code
23
+ COPY --chown=user . .
24
+
25
+ # Hugging Face Spaces expects the app to listen on port 7860
26
+ EXPOSE 7860
27
+
28
+ # Run the application with gunicorn
29
+ # Using 1 worker and 8 threads for a balance of performance and resource usage
30
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "8", "--timeout", "0", "app:app"]
Procfile ADDED
@@ -0,0 +1 @@
 
 
1
+ web: gunicorn app:app
README.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: EALPR FLASK
3
+ emoji: πŸš—
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Ain El Aql - Security & Admin Webpage
12
+
13
+
14
+ This is the Security and Administration dashboard for the **Ain El Aql** Parking Management System. It provides real-time license plate recognition (LPR), garage occupancy tracking, and manual gate control.
15
+
16
+ ## πŸš€ Deployment (Render)
17
+
18
+ This application is designed to be deployed on **Render**.
19
+
20
+ ### 1. Requirements
21
+ * A Render account.
22
+ * The code pushed to a GitHub repository.
23
+
24
+ ### 2. Environment Variables
25
+ Add the following variables in the Render "Environment" tab:
26
+ * `FLASK_SECRET_KEY`: A secret key for session security.
27
+ * `BACKEND_API_URL`: `https://ain-el-aql-backend-production.up.railway.app`
28
+ * `CAMERA_SOURCE`: The URL of your IP camera stream (e.g., `http://.../video`) or `0` for local testing if running locally.
29
+
30
+ ### 3. Build & Start Commands
31
+ * **Build Command**: `pip install -r requirements.txt`
32
+ * **Start Command**: `gunicorn "app:create_app()"`
33
+
34
+ ---
35
+
36
+ ## πŸ› οΈ Technology Stack
37
+ * **Frontend**: HTML5, CSS3 (Vanilla), JavaScript.
38
+ * **Backend**: Flask (Python).
39
+ * **OCR Engine**: YOLOv8 models hosted on **Hugging Face** via the Railway backend.
40
+ * **Database**: Supabase (via the Railway backend).
41
+
42
+ ---
43
+
44
+ ## πŸš— Features
45
+ * **Real-time Monitoring**: Live camera feed with periodic remote OCR polling.
46
+ * **Manual Override**: Open the gate manually from the dashboard.
47
+ * **History Ledger**: View complete records of all vehicles that entered or left the facility.
48
+ * **Garage Management**: Track currently inside vehicles and parking occupancy.
49
+
50
+ ---
51
+
52
+ ## πŸ”’ Security
53
+ * Role-based access control (Security vs Admin).
54
+ * Encrypted session tokens.
55
+ * CORS configured for cross-origin API calls.
56
+
57
+ ---
58
+
59
+ ## πŸ“ Usage
60
+ 1. **Login** with your security or admin credentials.
61
+ 2. **Start Camera** to begin monitoring the entrance/exit.
62
+ 3. **Scan & Process** an image to register a vehicle event (entry/exit).
63
+ 4. **Confirm Payments** in the Admin/Garage view for manual cash transactions.
__pycache__/config.cpython-311.pyc ADDED
Binary file (1.19 kB). View file
 
app.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ain El Aql β€” Security & Admin Dashboard
3
+ Flask application factory.
4
+ """
5
+
6
+ from flask import Flask
7
+ from werkzeug.middleware.proxy_fix import ProxyFix
8
+ from config import Config
9
+
10
+
11
+ def create_app():
12
+ app = Flask(__name__)
13
+ app.config.from_object(Config)
14
+
15
+ # Trust proxy headers (required for Hugging Face)
16
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
17
+
18
+ # Required for Hugging Face Spaces iframes
19
+ app.config.update(
20
+ SESSION_COOKIE_SAMESITE='None',
21
+ SESSION_COOKIE_SECURE=True,
22
+ )
23
+
24
+ # ── Register blueprints ──────────────────────────────────
25
+ from routes.auth_routes import auth_bp
26
+ from routes.admin_routes import admin_bp
27
+ from routes.security_routes import security_bp
28
+ from routes.camera_routes import camera_bp
29
+
30
+ app.register_blueprint(auth_bp)
31
+ app.register_blueprint(admin_bp)
32
+ app.register_blueprint(security_bp)
33
+ app.register_blueprint(camera_bp)
34
+
35
+ return app
36
+
37
+
38
+ app = create_app()
39
+
40
+
41
+ if __name__ == "__main__":
42
+ application = create_app()
43
+ application.run(debug=True, host="0.0.0.0", port=5000)
assets/emojis.com cctv-camera-emoji.png ADDED

Git LFS Details

  • SHA256: 5b76c73f6a66adb8e98d92a5bf3272b3a67869e29f7a11f142f5bfef9383022f
  • Pointer size: 131 Bytes
  • Size of remote file: 684 kB
config.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ load_dotenv(override=True)
5
+
6
+
7
+ class Config:
8
+ SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "fallback-dev-key")
9
+ BACKEND_API_URL = os.getenv("BACKEND_API_URL", "https://ain-el-aql-backend-production.up.railway.app")
10
+ ESP_GATE_URL = os.getenv("ESP_GATE_URL", "http://192.168.1.100/open")
11
+ PLATE_COOLDOWN_SECONDS = int(os.getenv("PLATE_COOLDOWN_SECONDS", "3"))
12
+ CAMERA_SOURCE = os.getenv("CAMERA_SOURCE", "0")
13
+ # If CAMERA_SOURCE is numeric string, convert to int
14
+ try:
15
+ CAMERA_SOURCE = int(CAMERA_SOURCE)
16
+ except ValueError:
17
+ pass
esp32/gate_controller.ino ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * Ain El Aql β€” ESP32 Gate Controller
3
+ *
4
+ * Two operation modes:
5
+ * 1. WiFi Mode: Polls the cloud backend every 1 second for gate commands.
6
+ * Works from anywhere in the world (real hardware + Wokwi simulation).
7
+ * 2. USB Serial Mode: Receives '1' from the browser's Web Serial API.
8
+ * Works when ESP32 is plugged into the security PC via USB.
9
+ *
10
+ * Both modes work simultaneously β€” whichever signal arrives first opens the gate.
11
+ */
12
+
13
+ #include <ESP32Servo.h>
14
+ #include <WiFi.h>
15
+ #include <HTTPClient.h>
16
+ #include <ArduinoJson.h>
17
+
18
+ // ── Configuration ─────────────────────────────────────────
19
+ // Change these to match your setup:
20
+ const char* WIFI_SSID = "YOUR_WIFI_SSID";
21
+ const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
22
+
23
+ // Your backend URL (Railway deployment)
24
+ const char* BACKEND_URL = "https://ain-el-aql-backend-production.up.railway.app";
25
+ const char* GATE_LOCATION = "OPERA";
26
+
27
+ // ── Hardware ──────────────────────────────────────────────
28
+ Servo gateServo;
29
+ const int servoPin = 18; // GPIO 18 on ESP32
30
+ const int LED_PIN = 2; // Built-in LED for status
31
+
32
+ // ── State ─────────────────────────────────────────────────
33
+ bool gateIsOpen = false;
34
+ unsigned long lastPollTime = 0;
35
+ const unsigned long POLL_INTERVAL_MS = 1000; // Poll every 1 second
36
+
37
+ void setup() {
38
+ Serial.begin(115200);
39
+ Serial.println("\n╔══════════════════════════════════════╗");
40
+ Serial.println("β•‘ Ain El Aql β€” Gate Controller v2 β•‘");
41
+ Serial.println("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•");
42
+
43
+ // Setup servo
44
+ ESP32PWM::allocateTimer(0);
45
+ gateServo.setPeriodHertz(50);
46
+ gateServo.attach(servoPin, 500, 2400);
47
+ gateServo.write(0); // Start closed
48
+
49
+ // Setup LED
50
+ pinMode(LED_PIN, OUTPUT);
51
+ digitalWrite(LED_PIN, LOW);
52
+
53
+ // Connect to WiFi
54
+ Serial.print("[WiFi] Connecting to ");
55
+ Serial.print(WIFI_SSID);
56
+ WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
57
+
58
+ int attempts = 0;
59
+ while (WiFi.status() != WL_CONNECTED && attempts < 30) {
60
+ delay(500);
61
+ Serial.print(".");
62
+ attempts++;
63
+ }
64
+
65
+ if (WiFi.status() == WL_CONNECTED) {
66
+ Serial.println(" βœ… Connected!");
67
+ Serial.print("[WiFi] IP: ");
68
+ Serial.println(WiFi.localIP());
69
+ digitalWrite(LED_PIN, HIGH); // LED on = WiFi connected
70
+ } else {
71
+ Serial.println(" ❌ WiFi failed. USB Serial mode only.");
72
+ }
73
+
74
+ Serial.println("[READY] Listening for commands (WiFi + USB Serial)");
75
+ Serial.println("[USB] Send '1' via Serial to open gate");
76
+ }
77
+
78
+ void openGate(int durationSeconds) {
79
+ if (gateIsOpen) return; // Prevent double-open
80
+
81
+ gateIsOpen = true;
82
+ Serial.println("🟒 GATE OPENING...");
83
+ gateServo.write(90);
84
+
85
+ // Blink LED while gate is open
86
+ for (int i = 0; i < durationSeconds * 2; i++) {
87
+ digitalWrite(LED_PIN, (i % 2 == 0) ? HIGH : LOW);
88
+ delay(500);
89
+
90
+ // Check for early close via serial
91
+ if (Serial.available() > 0) {
92
+ char cmd = Serial.read();
93
+ if (cmd == '0') {
94
+ Serial.println("⚑ Manual close command received");
95
+ break;
96
+ }
97
+ }
98
+ }
99
+
100
+ Serial.println("πŸ”΄ GATE CLOSING...");
101
+ gateServo.write(0);
102
+ digitalWrite(LED_PIN, WiFi.status() == WL_CONNECTED ? HIGH : LOW);
103
+ gateIsOpen = false;
104
+ }
105
+
106
+ HTTPClient http;
107
+ bool httpInitialized = false;
108
+
109
+ void pollBackend() {
110
+ if (WiFi.status() != WL_CONNECTED) return;
111
+ if (gateIsOpen) return;
112
+
113
+ if (!httpInitialized) {
114
+ http.setReuse(true);
115
+ httpInitialized = true;
116
+ }
117
+
118
+ String url = String(BACKEND_URL) + "/gate/poll?location=" + GATE_LOCATION;
119
+
120
+ http.begin(url);
121
+ http.setTimeout(2000);
122
+ int httpCode = http.GET();
123
+
124
+ if (httpCode == 200) {
125
+ String response = http.getString();
126
+
127
+ // Parse JSON
128
+ StaticJsonDocument<512> doc;
129
+ DeserializationError error = deserializeJson(doc, response);
130
+
131
+ if (!error) {
132
+ const char* status = doc["status"];
133
+
134
+ if (strcmp(status, "command") == 0) {
135
+ const char* action = doc["command"]["action"];
136
+ int duration = doc["command"]["duration_seconds"] | 10;
137
+ const char* plate = doc["command"]["plate"] | "Unknown";
138
+
139
+ Serial.print("πŸ“‘ Cloud command: ");
140
+ Serial.print(action);
141
+ Serial.println("s");
142
+
143
+ if (strcmp(action, "open") == 0) {
144
+ openGate(duration);
145
+ }
146
+ }
147
+ }
148
+ } else if (httpCode < 0) {
149
+ // If error (e.g. timeout), end and re-init next time
150
+ http.end();
151
+ httpInitialized = false;
152
+ }
153
+ // If 200 OK, we DO NOT call http.end() so the TLS socket stays alive!
154
+ }
155
+
156
+ void loop() {
157
+ // ── Mode 1: USB Serial Commands ───────────────────────
158
+ if (Serial.available() > 0) {
159
+ char cmd = Serial.read();
160
+ if (cmd == '1') {
161
+ Serial.println("πŸ”Œ USB command received!");
162
+ openGate(10);
163
+ }
164
+ }
165
+
166
+ // ── Mode 2: WiFi Cloud Polling ────────────────────────
167
+ unsigned long now = millis();
168
+ if (now - lastPollTime >= POLL_INTERVAL_MS) {
169
+ lastPollTime = now;
170
+ pollBackend();
171
+ }
172
+
173
+ // Small delay to prevent busy-waiting
174
+ delay(10);
175
+ }
models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Models package
models/car_plate_best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:43a6f0f8e08e140a9c2958ae95aec5b2206a18e18343e74f328d1ae56fd06efa
3
+ size 40522154
models/model_ocr.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4bbff2d2eccb0b249cb783ad17fbdf1da9914813ad9738d3eb5555798e614acd
3
+ size 52068370
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask==3.1.1
2
+ requests==2.32.3
3
+ python-dotenv==1.1.0
4
+ opencv-python-headless==4.11.0.86
5
+ gunicorn==23.0.0
routes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Routes package
routes/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (171 Bytes). View file
 
routes/__pycache__/admin_routes.cpython-311.pyc ADDED
Binary file (9.45 kB). View file
 
routes/__pycache__/auth_routes.cpython-311.pyc ADDED
Binary file (6.42 kB). View file
 
routes/__pycache__/camera_routes.cpython-311.pyc ADDED
Binary file (4 kB). View file
 
routes/__pycache__/security_routes.cpython-311.pyc ADDED
Binary file (7.58 kB). View file
 
routes/admin_routes.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ admin_routes.py
3
+ Admin dashboard β€” full management panel.
4
+ """
5
+
6
+ from flask import (
7
+ Blueprint, render_template, request, session,
8
+ redirect, url_for, flash, jsonify
9
+ )
10
+ from services.api_service import (
11
+ get_inside_cars, get_entered_cars, get_left_cars, get_leaving_soon,
12
+ get_parking_history, get_parking_locations, get_parking_occupancy,
13
+ get_all_users,
14
+ get_my_vehicles, update_profile, add_vehicle, edit_vehicle,
15
+ delete_vehicle, weekly_refresh, confirm_cash_payment
16
+ )
17
+
18
+ admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
19
+
20
+
21
+ def _require_admin():
22
+ if "token" not in session or session.get("role") != "admin":
23
+ return redirect(url_for("auth.login_page"))
24
+ return None
25
+
26
+
27
+ @admin_bp.before_request
28
+ def before():
29
+ result = _require_admin()
30
+ if result:
31
+ return result
32
+
33
+
34
+ @admin_bp.route("/")
35
+ def dashboard():
36
+ token = session["token"]
37
+ occupancy = get_parking_occupancy(token)
38
+ locations = get_parking_locations(token)
39
+ inside = get_inside_cars(token)
40
+ return render_template(
41
+ "admin/dashboard.html",
42
+ user=session.get("user", {}),
43
+ occupancy=occupancy,
44
+ locations=locations,
45
+ inside_cars=inside,
46
+ )
47
+
48
+
49
+ @admin_bp.route("/vehicles")
50
+ def vehicles():
51
+ token = session["token"]
52
+ return render_template("admin/vehicles.html", user=session.get("user", {}))
53
+
54
+
55
+ @admin_bp.route("/history")
56
+ def history():
57
+ token = session["token"]
58
+ hist = get_parking_history(token)
59
+ return render_template("admin/history.html", user=session.get("user", {}), history=hist)
60
+
61
+
62
+ @admin_bp.route("/garage")
63
+ def garage():
64
+ token = session["token"]
65
+ locations = get_parking_locations(token)
66
+ occupancy = get_parking_occupancy(token)
67
+ inside = get_inside_cars(token)
68
+ entered = get_entered_cars(token)
69
+ left = get_left_cars(token)
70
+ leaving = get_leaving_soon(token)
71
+ return render_template(
72
+ "admin/garage.html",
73
+ user=session.get("user", {}),
74
+ locations=locations,
75
+ occupancy=occupancy,
76
+ inside_cars=inside,
77
+ entered_cars=entered,
78
+ left_cars=left,
79
+ leaving_cars=leaving,
80
+ )
81
+
82
+
83
+ @admin_bp.route("/users")
84
+ def users():
85
+ return render_template("admin/users.html", user=session.get("user", {}))
86
+
87
+
88
+ @admin_bp.route("/api/users")
89
+ def api_users():
90
+ return jsonify(get_all_users(session["token"]))
91
+
92
+
93
+ # ── AJAX API endpoints ──────────────────────────────────────
94
+ @admin_bp.route("/api/occupancy")
95
+ def api_occupancy():
96
+ return jsonify(get_parking_occupancy(session["token"]))
97
+
98
+
99
+ @admin_bp.route("/api/locations")
100
+ def api_locations():
101
+ return jsonify(get_parking_locations(session["token"]))
102
+
103
+
104
+ @admin_bp.route("/api/inside-cars")
105
+ def api_inside_cars():
106
+ return jsonify(get_inside_cars(session["token"]))
107
+
108
+
109
+ @admin_bp.route("/api/entered-cars")
110
+ def api_entered_cars():
111
+ return jsonify(get_entered_cars(session["token"]))
112
+
113
+
114
+ @admin_bp.route("/api/left-cars")
115
+ def api_left_cars():
116
+ return jsonify(get_left_cars(session["token"]))
117
+
118
+
119
+ @admin_bp.route("/api/leaving-soon")
120
+ def api_leaving_soon():
121
+ return jsonify(get_leaving_soon(session["token"]))
122
+
123
+
124
+ @admin_bp.route("/api/history")
125
+ def api_history():
126
+ return jsonify(get_parking_history(session["token"]))
127
+
128
+
129
+ @admin_bp.route("/api/vehicles/add", methods=["POST"])
130
+ def api_add_vehicle():
131
+ data = request.get_json() or {}
132
+ return jsonify(add_vehicle(session["token"], data))
133
+
134
+
135
+ @admin_bp.route("/api/vehicles/<vehicle_id>", methods=["PUT"])
136
+ def api_edit_vehicle(vehicle_id):
137
+ data = request.get_json() or {}
138
+ return jsonify(edit_vehicle(session["token"], vehicle_id, data))
139
+
140
+
141
+ @admin_bp.route("/api/vehicles/<vehicle_id>", methods=["DELETE"])
142
+ def api_delete_vehicle(vehicle_id):
143
+ return jsonify(delete_vehicle(session["token"], vehicle_id))
144
+
145
+
146
+ @admin_bp.route("/api/profile/update", methods=["PUT"])
147
+ def api_update_profile():
148
+ data = request.get_json() or {}
149
+ return jsonify(update_profile(session["token"], data))
150
+
151
+
152
+ @admin_bp.route("/api/cash-confirm", methods=["POST"])
153
+ def api_cash_confirm():
154
+ data = request.get_json() or {}
155
+ return jsonify(confirm_cash_payment(session["token"], data))
156
+
157
+
158
+ @admin_bp.route("/api/maintenance", methods=["POST"])
159
+ def api_maintenance():
160
+ return jsonify(weekly_refresh(session["token"]))
routes/auth_routes.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ auth_routes.py
3
+ Login / Register / Logout β€” redirects based on role.
4
+ """
5
+
6
+ from flask import (
7
+ Blueprint, render_template, request, redirect,
8
+ url_for, session, flash
9
+ )
10
+ from services.api_service import login, register
11
+
12
+ auth_bp = Blueprint("auth", __name__)
13
+
14
+
15
+ @auth_bp.route("/")
16
+ def index():
17
+ if "token" in session:
18
+ role = session.get("role", "user")
19
+ if role == "admin":
20
+ return redirect(url_for("admin.dashboard"))
21
+ elif role == "security":
22
+ return redirect(url_for("security.dashboard"))
23
+ return redirect(url_for("auth.login_page"))
24
+
25
+
26
+ @auth_bp.route("/login", methods=["GET", "POST"])
27
+ def login_page():
28
+ if request.method == "POST":
29
+ identifier = request.form.get("identifier", "").strip()
30
+ password = request.form.get("password", "").strip()
31
+ if not identifier or not password:
32
+ flash("Please fill in all fields.", "error")
33
+ return render_template("auth/login.html")
34
+
35
+ result = login(identifier, password)
36
+
37
+ if "error" in result:
38
+ flash(f"Login failed: {result['error']}", "error")
39
+ return render_template("auth/login.html")
40
+
41
+ # Extract token and role from response (handle different backend structures)
42
+ sess_data = result.get("session", {})
43
+ user_data = result.get("user", result) # Fallback to root for user data
44
+
45
+ token = sess_data.get("access_token") or result.get("access_token")
46
+ role = user_data.get("role") or result.get("role", "user")
47
+ is_staff = user_data.get("is_staff", result.get("is_staff", False))
48
+
49
+ if not token:
50
+ flash("Login failed: no token received from backend.", "error")
51
+ return render_template("auth/login.html")
52
+
53
+ # Only allow admin / security roles
54
+ if role not in ("admin", "security"):
55
+ flash("Access denied. This dashboard is for staff only.", "error")
56
+ return render_template("auth/login.html")
57
+
58
+ # Store session
59
+ session["token"] = token
60
+ session["refresh_token"] = sess_data.get("refresh_token", "")
61
+ session["role"] = role
62
+ session["is_staff"] = is_staff
63
+ session["user"] = user_data
64
+
65
+ if role == "admin":
66
+ return redirect(url_for("admin.dashboard"))
67
+ else:
68
+ return redirect(url_for("security.dashboard"))
69
+
70
+ return render_template("auth/login.html")
71
+
72
+
73
+ @auth_bp.route("/register", methods=["GET", "POST"])
74
+ def register_page():
75
+ if request.method == "POST":
76
+ payload = {
77
+ "first_name": request.form.get("first_name", "").strip(),
78
+ "last_name": request.form.get("last_name", "").strip(),
79
+ "username": request.form.get("username", "").strip(),
80
+ "phone": request.form.get("phone", "").strip(),
81
+ "password": request.form.get("password", "").strip(),
82
+ "email": request.form.get("email", "").strip(),
83
+ "nid": request.form.get("nid", "").strip(),
84
+ "role": request.form.get("role", "security").strip(),
85
+ }
86
+ if not payload["username"] or not payload["phone"] or not payload["password"] or not payload["email"]:
87
+ flash("Username, phone, email, and password are required.", "error")
88
+ return render_template("auth/register.html")
89
+
90
+ result = register(payload)
91
+ if "error" in result:
92
+ flash(f"Registration failed: {result['error']}", "error")
93
+ return render_template("auth/register.html")
94
+
95
+ flash("Registration successful! Please log in.", "success")
96
+ return redirect(url_for("auth.login_page"))
97
+
98
+ return render_template("auth/register.html")
99
+
100
+
101
+ @auth_bp.route("/logout")
102
+ def logout():
103
+ session.clear()
104
+ flash("Logged out.", "info")
105
+ return redirect(url_for("auth.login_page"))
routes/camera_routes.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ camera_routes.py
3
+ Camera start/stop and MJPEG video feed.
4
+ """
5
+
6
+ from flask import Blueprint, Response, jsonify, session, redirect, url_for
7
+ from services.camera_service import camera
8
+
9
+ camera_bp = Blueprint("camera", __name__, url_prefix="/camera")
10
+
11
+
12
+ @camera_bp.route("/start", methods=["POST"])
13
+ def start():
14
+ if "token" not in session:
15
+ return jsonify({"error": "Not authenticated"}), 401
16
+ try:
17
+ from flask import current_app
18
+ source = current_app.config.get("CAMERA_SOURCE", 0)
19
+ camera.start(source=source)
20
+ return jsonify({"status": "started"})
21
+ except RuntimeError as e:
22
+ return jsonify({"error": str(e)}), 500
23
+
24
+
25
+ @camera_bp.route("/stop", methods=["POST"])
26
+ def stop():
27
+ if "token" not in session:
28
+ return jsonify({"error": "Not authenticated"}), 401
29
+ camera.stop()
30
+ return jsonify({"status": "stopped"})
31
+
32
+
33
+ @camera_bp.route("/status")
34
+ def status():
35
+ return jsonify({"running": camera.is_running})
36
+
37
+
38
+ @camera_bp.route("/video_feed")
39
+ def video_feed():
40
+ if not camera.is_running:
41
+ return "Camera not running", 503
42
+ return Response(
43
+ camera.generate_mjpeg(),
44
+ mimetype="multipart/x-mixed-replace; boundary=frame",
45
+ )
46
+
47
+
48
+ @camera_bp.route("/capture")
49
+ def capture():
50
+ """Return a single JPEG frame."""
51
+ if "token" not in session:
52
+ return jsonify({"error": "Not authenticated"}), 401
53
+ frame = camera.get_frame()
54
+ if frame is None:
55
+ return "No frame available", 503
56
+ return Response(frame, mimetype="image/jpeg")
57
+
58
+ @camera_bp.route("/latest_ocr")
59
+ def latest_ocr():
60
+ """Return the latest OCR results from the live stream."""
61
+ if not camera.is_running:
62
+ return jsonify({"plate_key": None})
63
+ return jsonify({
64
+ "plate_key": getattr(camera, "latest_plate_key", None),
65
+ "ar_letters": getattr(camera, "latest_ar_letters", None),
66
+ "ar_numbers": getattr(camera, "latest_ar_numbers", None),
67
+ })
routes/security_routes.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ security_routes.py
3
+ Security guard dashboard β€” OCR scanning, gate control, live monitoring.
4
+ """
5
+
6
+ import base64
7
+ from flask import (
8
+ Blueprint, render_template, request, session,
9
+ redirect, url_for, flash, jsonify
10
+ )
11
+ from services.api_service import (
12
+ predict, is_on_cooldown, get_inside_cars,
13
+ get_new_cars, trigger_gate_open, confirm_cash_payment,
14
+ get_parking_occupancy, get_gate_decision
15
+ )
16
+
17
+ security_bp = Blueprint("security", __name__, url_prefix="/security")
18
+
19
+
20
+ def _require_security():
21
+ if "token" not in session or session.get("role") not in ("security", "admin"):
22
+ return redirect(url_for("auth.login_page"))
23
+ return None
24
+
25
+
26
+ @security_bp.before_request
27
+ def before():
28
+ result = _require_security()
29
+ if result:
30
+ return result
31
+
32
+
33
+ @security_bp.route("/", methods=["GET", "POST"])
34
+ def dashboard():
35
+ token = session["token"]
36
+
37
+ # --- OCR Scanner Logic ---
38
+ result = None
39
+ if request.method == "POST":
40
+ event_type = request.form.get("event_type", "entry")
41
+ location = request.form.get("parking_location", "OPERA")
42
+
43
+ image_data = None
44
+ filename = "capture.jpg"
45
+
46
+ if "image" in request.files and request.files["image"].filename:
47
+ f = request.files["image"]
48
+ image_data = f.read()
49
+ filename = f.filename
50
+ elif request.form.get("camera_frame"):
51
+ b64 = request.form["camera_frame"]
52
+ if "," in b64:
53
+ b64 = b64.split(",", 1)[1]
54
+ image_data = base64.b64decode(b64)
55
+ filename = "camera_capture.jpg"
56
+
57
+ if not image_data:
58
+ flash("No image provided.", "error")
59
+ else:
60
+ # Send to backend to register event (Real Backend)
61
+ result = predict(image_data, filename, event_type, location, token)
62
+
63
+ # Backend plate check (fallback)
64
+ plate_info = result.get("plate_info") or result.get("plate") or {}
65
+ if isinstance(plate_info, dict):
66
+ plate_key = plate_info.get("plate_key", "") or plate_info.get("plate_ar", "")
67
+ elif isinstance(plate_info, str):
68
+ plate_key = plate_info
69
+ else:
70
+ plate_key = ""
71
+
72
+ if plate_key and is_on_cooldown(plate_key):
73
+ result["cooldown_warning"] = True
74
+
75
+ # Gate is now triggered exclusively by the frontend dashboard to avoid double-opens
76
+ pass
77
+
78
+ # --- Dashboard Logic ---
79
+ occupancy = get_parking_occupancy(token)
80
+ inside = get_inside_cars(token)
81
+
82
+ # If AJAX request from the scanner, return JSON
83
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
84
+ return jsonify(result)
85
+
86
+ return render_template(
87
+ "security/dashboard.html",
88
+ user=session.get("user", {}),
89
+ occupancy=occupancy,
90
+ inside_cars=inside,
91
+ result=result
92
+ )
93
+
94
+
95
+ @security_bp.route("/api/new-cars")
96
+ def api_new_cars():
97
+ """AJAX endpoint for live polling."""
98
+ token = session.get("token")
99
+ return jsonify(get_new_cars(token))
100
+
101
+
102
+ @security_bp.route("/api/inside-cars")
103
+ def api_inside_cars():
104
+ token = session.get("token")
105
+ return jsonify(get_inside_cars(token))
106
+
107
+
108
+ @security_bp.route("/api/occupancy")
109
+ def api_occupancy():
110
+ token = session.get("token")
111
+ return jsonify(get_parking_occupancy(token))
112
+
113
+
114
+ @security_bp.route("/api/cash-confirm", methods=["POST"])
115
+ def api_cash_confirm():
116
+ """Security confirms cash payment and triggers exit closure."""
117
+ token = session.get("token")
118
+ data = request.get_json() or {}
119
+
120
+ # 1. Confirm the cash payment in the backend
121
+ result = confirm_cash_payment(token, data)
122
+
123
+ # 2. Trigger an explicit gate decision (exit) to archive the session
124
+ # This prevents the need for a second scan to mark the car as "left"
125
+ plate_key = data.get("plate_key") or data.get("plate_arabic")
126
+ location = data.get("parking_location") or "OPERA"
127
+
128
+ if plate_key and not result.get("error"):
129
+ # We fire and forget or just log the decision to ensure backend archives the session
130
+ get_gate_decision(token, {
131
+ "plate_key": plate_key,
132
+ "event_type": "exit",
133
+ "parking_location": location
134
+ })
135
+
136
+ return jsonify(result)
137
+
138
+
139
+ @security_bp.route("/api/gate-open", methods=["POST"])
140
+ def api_gate_open():
141
+ """Manual gate open trigger."""
142
+ return jsonify(trigger_gate_open())
143
+
144
+
145
+ @security_bp.route("/api/live-ocr", methods=["POST"])
146
+ def api_live_ocr():
147
+ """Lightweight OCR endpoint for live browser frames."""
148
+ data = request.get_json() or {}
149
+ b64 = data.get("image")
150
+ if not b64:
151
+ return jsonify({"error": "No image"}), 400
152
+
153
+ if "," in b64:
154
+ b64 = b64.split(",", 1)[1]
155
+ image_data = base64.b64decode(b64)
156
+
157
+ # Call backend but with ocr_scan event type
158
+ result = predict(image_data, "live_frame.jpg", event_type="ocr_scan", token=session.get("token"))
159
+ return jsonify(result)
scratch/test_railway.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+
3
+ url = "https://railway.com/project/6030af2c-06da-4c86-9ef7-aa68fedee8cd/service/68f17b61-afdb-42e7-a374-1049dd196786?environmentId=be637a41-505e-4f28-b149-93e6af05d4d0/auth/login"
4
+ r = requests.post(url, json={"identifier": "EzzeldeenQutbS", "password": "password"}, headers={"Accept": "application/json"})
5
+ print(r.status_code)
6
+ try:
7
+ print(r.json())
8
+ except:
9
+ print(r.text[:200])
services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Services package
services/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (173 Bytes). View file
 
services/__pycache__/api_service.cpython-311.pyc ADDED
Binary file (21.8 kB). View file
 
services/__pycache__/camera_service.cpython-311.pyc ADDED
Binary file (4.39 kB). View file
 
services/__pycache__/ocr_service.cpython-311.pyc ADDED
Binary file (5.43 kB). View file
 
services/api_service.py ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api_service.py
3
+ Thin wrapper around the Railway backend API.
4
+ All HTTP calls to the remote backend go through here.
5
+ """
6
+
7
+ import requests
8
+ import time
9
+ from flask import current_app
10
+
11
+ # ── Cooldown tracking ────────────────────────────────────────
12
+ _plate_last_seen: dict[str, float] = {}
13
+
14
+
15
+ import os
16
+
17
+ def _base_url() -> str:
18
+ try:
19
+ from flask import current_app
20
+ return current_app.config["BACKEND_API_URL"].rstrip("/")
21
+ except:
22
+ return os.getenv("BACKEND_API_URL", "https://ain-el-aql-backend-production.up.railway.app").rstrip("/")
23
+
24
+
25
+ def _headers(token: str | None = None) -> dict:
26
+ h = {"Accept": "application/json"}
27
+ if token:
28
+ h["Authorization"] = f"Bearer {token}"
29
+ return h
30
+
31
+
32
+ def normalize_arabic_plate(text: str) -> str:
33
+ """Converts Hindi digits to Western digits and handles formatting."""
34
+ if not text: return ""
35
+ hindi_to_western = {
36
+ 'Ω ': '0', 'Ω‘': '1', 'Ω’': '2', 'Ω£': '3', 'Ω€': '4',
37
+ 'Ω₯': '5', 'Ω¦': '6', 'Ω§': '7', 'Ω¨': '8', 'Ω©': '9'
38
+ }
39
+
40
+ # Convert digits
41
+ res = ""
42
+ for char in text:
43
+ res += hindi_to_western.get(char, char)
44
+
45
+ if '|' in res:
46
+ parts = res.split('|')
47
+ letters = parts[0].strip()
48
+ numbers = parts[1].strip().replace(" ", "")
49
+ # Reverse numbers for correct LTR display in the browser
50
+ return f"{letters} | {numbers[::-1]}"
51
+
52
+ return res
53
+
54
+
55
+ def extract_match_key(text: str) -> str:
56
+ """
57
+ Extracts alphanumeric characters and sorts them (Letters then Numbers)
58
+ to ensure 'SQR 777' and '777 SQR' generate the same key 'SQR777'.
59
+ """
60
+ if not text: return ""
61
+ text = text.replace(" ", "").replace("|", "").replace("-", "")
62
+
63
+ import re
64
+ letters = "".join(re.findall(r'[a-zA-Z\u0600-\u06FF]+', text))
65
+ numbers = "".join(re.findall(r'[0-9]+', text))
66
+
67
+ # Egyptian system usually prefers Letters then Numbers
68
+ return f"{letters}{numbers}"
69
+
70
+
71
+ # ── Health ───────────────────────────────────────────────────
72
+ def check_health() -> dict:
73
+ try:
74
+ r = requests.get(f"{_base_url()}/health", timeout=5)
75
+ return r.json()
76
+ except Exception as e:
77
+ return {"status": "error", "detail": str(e)}
78
+
79
+
80
+ def check_supabase_health() -> dict:
81
+ try:
82
+ r = requests.get(f"{_base_url()}/supabase/health", timeout=5)
83
+ return r.json()
84
+ except Exception as e:
85
+ return {"status": "error", "detail": str(e)}
86
+
87
+
88
+ # ── Auth ─────────────────────────────────────────────────────
89
+ def login(identifier: str, password: str) -> dict:
90
+ """POST /auth/login β†’ returns user + session."""
91
+ try:
92
+ r = requests.post(
93
+ f"{_base_url()}/auth/login",
94
+ json={"identifier": identifier, "password": password},
95
+ headers=_headers(),
96
+ timeout=30,
97
+ )
98
+
99
+ if r.status_code != 200:
100
+ try:
101
+ error_msg = r.json().get("detail", r.json().get("error", "Unknown error"))
102
+ except:
103
+ error_msg = f"Error {r.status_code}: {r.text[:100]}"
104
+ return {"error": error_msg}
105
+
106
+ return r.json()
107
+ except Exception as e:
108
+ return {"error": str(e)}
109
+
110
+
111
+ def register(payload: dict) -> dict:
112
+ """POST /auth/register"""
113
+ try:
114
+ r = requests.post(
115
+ f"{_base_url()}/auth/register",
116
+ json=payload,
117
+ headers=_headers(),
118
+ timeout=10,
119
+ )
120
+ return r.json()
121
+ except Exception as e:
122
+ return {"error": str(e)}
123
+
124
+
125
+ # ── Predict / OCR ────────────────────────────────────────────
126
+ def predict(image_bytes: bytes, filename: str, event_type: str = "entry",
127
+ location: str = "OPERA", token: str | None = None) -> dict:
128
+ """
129
+ POST /process-image (Railway) β†’ send image for OCR.
130
+ Then optionally call /gate/decision to get registration/gate status.
131
+ """
132
+ try:
133
+ # 1. Get OCR from the Main Engine
134
+ # Backend expects 'image' field for the file, and 'camera_source'
135
+ files = {"image": (filename, image_bytes, "image/jpeg")}
136
+ data = {
137
+ "camera_source": location,
138
+ "parking_location": location,
139
+ "event_type": event_type
140
+ }
141
+
142
+ ocr_resp = requests.post(
143
+ f"{_base_url()}/predict",
144
+ files=files,
145
+ data=data,
146
+ headers=_headers(token) if token else _headers(),
147
+ timeout=30,
148
+ )
149
+
150
+ if ocr_resp.status_code != 200:
151
+ return {"error": f"Backend OCR failed: {ocr_resp.text[:100]}"}
152
+
153
+ resp = ocr_resp.json()
154
+
155
+ # 2. Map OCR results (Robust mapping)
156
+ det = resp.get("detection") or resp
157
+ urls = resp.get("urls") or resp.get("images") or {}
158
+
159
+ # Try different possible plate field names including new backend format
160
+ plate_info_dict = resp.get("plate_info", {})
161
+ plate_text = det.get("plate_text") or det.get("plate_ar") or det.get("plate") or plate_info_dict.get("arabic") or ""
162
+ en_text = det.get("en_text") or det.get("plate_en") or plate_info_dict.get("english") or ""
163
+
164
+ if plate_text or en_text:
165
+ normalized_ar = normalize_arabic_plate(plate_text)
166
+ match_key = extract_match_key(en_text) or extract_match_key(normalized_ar)
167
+
168
+ # Use the registration info directly if the backend already provided it
169
+ # Different backends might use 'app_registration' or top-level 'matched'
170
+ reg_info = resp.get("app_registration") or resp.get("registration") or {}
171
+ is_registered = False
172
+ if isinstance(reg_info, dict):
173
+ is_registered = reg_info.get("is_registered", resp.get("matched", False))
174
+ elif isinstance(reg_info, bool):
175
+ is_registered = reg_info
176
+
177
+ resp["matched"] = is_registered
178
+ resp["plate_text_ar"] = normalized_ar
179
+ resp["plate_info"] = {
180
+ "plate_ar": normalized_ar,
181
+ "plate_en": en_text,
182
+ "plate_key": match_key,
183
+ "confidence": det.get("confidence", det.get("score", 0))
184
+ }
185
+
186
+ # Map Images
187
+ admin_page_data = resp.get("admin_page", {})
188
+ if "admin_page" not in resp:
189
+ resp["admin_page"] = {}
190
+
191
+ # If backend returns base64, create a data URL
192
+ if admin_page_data.get("annotated_image_base64"):
193
+ b64 = admin_page_data["annotated_image_base64"]
194
+ # Sometimes it might already have the prefix
195
+ if b64.startswith("data:"):
196
+ resp["admin_page"]["processed_image_url"] = b64
197
+ else:
198
+ resp["admin_page"]["processed_image_url"] = f"data:image/jpeg;base64,{b64}"
199
+ else:
200
+ resp["admin_page"]["processed_image_url"] = urls.get("processed") or urls.get("annotated") or ""
201
+
202
+ resp["admin_page"]["raw_image_url"] = urls.get("raw") or urls.get("original") or ""
203
+
204
+ # 3. Call Gate Decision (Business Logic)
205
+ if "gate_decision" not in resp:
206
+ try:
207
+ decision_payload = {
208
+ "plate_key": resp["plate_info"]["plate_key"],
209
+ "event_type": event_type,
210
+ "parking_location": location
211
+ }
212
+ dr = requests.post(
213
+ f"{_base_url()}/gate/decision",
214
+ json=decision_payload,
215
+ headers=_headers(token) if token else _headers(),
216
+ timeout=10
217
+ )
218
+ if dr.status_code == 200:
219
+ decision_data = dr.json()
220
+ resp.update(decision_data)
221
+ except Exception as e:
222
+ resp["backend_warning"] = f"Gate decision failed: {str(e)}"
223
+
224
+ return resp
225
+ except Exception as e:
226
+ return {"error": str(e)}
227
+
228
+
229
+ def is_on_cooldown(plate_key: str) -> bool:
230
+ """Returns True if plate was scanned within PLATE_COOLDOWN_SECONDS."""
231
+ cooldown = current_app.config["PLATE_COOLDOWN_SECONDS"]
232
+ now = time.time()
233
+ if plate_key in _plate_last_seen:
234
+ if now - _plate_last_seen[plate_key] < cooldown:
235
+ return True
236
+ _plate_last_seen[plate_key] = now
237
+ return False
238
+
239
+
240
+ # ── Parking / Events (staff endpoints) ─────────────────────
241
+ def get_entered_cars(token: str) -> dict:
242
+ try:
243
+ r = requests.get(f"{_base_url()}/events/entered-cars",
244
+ headers=_headers(token), timeout=30)
245
+ return r.json()
246
+ except Exception as e:
247
+ return {"error": str(e)}
248
+
249
+
250
+ def get_inside_cars(token: str) -> dict:
251
+ try:
252
+ r = requests.get(f"{_base_url()}/events/inside-cars",
253
+ headers=_headers(token), timeout=30)
254
+ return r.json()
255
+ except Exception as e:
256
+ return {"error": str(e)}
257
+
258
+
259
+ def get_left_cars(token: str) -> dict:
260
+ try:
261
+ r = requests.get(f"{_base_url()}/events/left-cars",
262
+ headers=_headers(token), timeout=30)
263
+ return r.json()
264
+ except Exception as e:
265
+ return {"error": str(e)}
266
+
267
+
268
+ def get_leaving_soon(token: str) -> dict:
269
+ try:
270
+ r = requests.get(f"{_base_url()}/events/leaving-cars-within-5-minutes",
271
+ headers=_headers(token), timeout=30)
272
+ return r.json()
273
+ except Exception as e:
274
+ return {"error": str(e)}
275
+
276
+
277
+ def get_new_cars(token: str) -> dict:
278
+ """GET /events/new-cars β€” staff-only real-time polling feed."""
279
+ try:
280
+ r = requests.get(f"{_base_url()}/events/new-cars",
281
+ headers=_headers(token), timeout=30)
282
+ return r.json()
283
+ except Exception as e:
284
+ return {"error": str(e)}
285
+
286
+
287
+ def get_parking_history(token: str) -> dict:
288
+ try:
289
+ r = requests.get(f"{_base_url()}/parking/history",
290
+ headers=_headers(token), timeout=30)
291
+ return r.json()
292
+ except Exception as e:
293
+ return {"error": str(e)}
294
+
295
+
296
+ def get_all_users(token: str) -> dict:
297
+ try:
298
+ r = requests.get(f"{_base_url()}/users",
299
+ headers=_headers(token), timeout=30)
300
+ return r.json()
301
+ except Exception as e:
302
+ return {"error": str(e)}
303
+
304
+
305
+ def get_parking_locations(token: str) -> dict:
306
+ try:
307
+ r = requests.get(f"{_base_url()}/parking/locations",
308
+ headers=_headers(token), timeout=30)
309
+ return r.json()
310
+ except Exception as e:
311
+ return {"error": str(e)}
312
+
313
+
314
+ def get_parking_occupancy(token: str) -> dict:
315
+ try:
316
+ r = requests.get(f"{_base_url()}/parking/occupancy",
317
+ headers=_headers(token), timeout=30)
318
+ return r.json()
319
+ except Exception as e:
320
+ return {"error": str(e)}
321
+
322
+
323
+ # ── Vehicles ─────────────────────────────────────────────────
324
+ def get_my_vehicles(token: str) -> dict:
325
+ try:
326
+ r = requests.get(f"{_base_url()}/vehicles/my",
327
+ headers=_headers(token), timeout=30)
328
+ return r.json()
329
+ except Exception as e:
330
+ return {"error": str(e)}
331
+
332
+
333
+ def add_vehicle(token: str, payload: dict) -> dict:
334
+ try:
335
+ r = requests.post(f"{_base_url()}/vehicles/add",
336
+ json=payload, headers=_headers(token), timeout=30)
337
+ return r.json()
338
+ except Exception as e:
339
+ return {"error": str(e)}
340
+
341
+
342
+ def edit_vehicle(token: str, vehicle_id: str, payload: dict) -> dict:
343
+ try:
344
+ r = requests.put(f"{_base_url()}/vehicles/{vehicle_id}",
345
+ json=payload, headers=_headers(token), timeout=30)
346
+ return r.json()
347
+ except Exception as e:
348
+ return {"error": str(e)}
349
+
350
+
351
+ def delete_vehicle(token: str, vehicle_id: str) -> dict:
352
+ try:
353
+ r = requests.delete(f"{_base_url()}/vehicles/{vehicle_id}",
354
+ headers=_headers(token), timeout=30)
355
+ return r.json()
356
+ except Exception as e:
357
+ return {"error": str(e)}
358
+
359
+
360
+ # ── Profile ──────────────────────────────────────────────────
361
+ def update_profile(token: str, payload: dict) -> dict:
362
+ try:
363
+ r = requests.put(f"{_base_url()}/profile/update",
364
+ json=payload, headers=_headers(token), timeout=30)
365
+ return r.json()
366
+ except Exception as e:
367
+ return {"error": str(e)}
368
+
369
+
370
+ # ── Payments ─────────────────────────────────────────────────
371
+ def confirm_cash_payment(token: str, payload: dict) -> dict:
372
+ """POST /payments/manual/cash-confirm β€” staff-only."""
373
+ try:
374
+ r = requests.post(f"{_base_url()}/payments/manual/cash-confirm",
375
+ json=payload, headers=_headers(token), timeout=30)
376
+ return r.json()
377
+ except Exception as e:
378
+ return {"error": str(e)}
379
+
380
+
381
+ def get_gate_decision(token: str, payload: dict) -> dict:
382
+ try:
383
+ r = requests.post(f"{_base_url()}/gate/decision",
384
+ json=payload, headers=_headers(token), timeout=30)
385
+ return r.json()
386
+ except Exception as e:
387
+ return {"error": str(e)}
388
+
389
+
390
+ # ── Gate hardware (ESP) ──────────────────────────────────────
391
+ def trigger_gate_open(plate: str = "", location: str = "OPERA") -> dict:
392
+ """Queue a gate-open command on the cloud backend for ESP32 devices to poll."""
393
+ try:
394
+ r = requests.post(
395
+ f"{_base_url()}/gate/trigger",
396
+ json={
397
+ "parking_location": location,
398
+ "duration_seconds": 10,
399
+ "plate": plate,
400
+ },
401
+ timeout=5,
402
+ )
403
+ return {"status": "sent", "backend_response": r.json()}
404
+ except Exception as e:
405
+ return {"status": "error", "detail": str(e)}
406
+
407
+
408
+ # ── Admin maintenance ────────────────────────────────────────
409
+ def weekly_refresh(token: str) -> dict:
410
+ try:
411
+ r = requests.post(f"{_base_url()}/admin/maintenance/weekly-refresh",
412
+ headers=_headers(token), timeout=15)
413
+ return r.json()
414
+ except Exception as e:
415
+ return {"error": str(e)}
services/camera_service.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ camera_service.py
3
+ Manages OpenCV camera capture. Start/Stop on demand (not continuous).
4
+ """
5
+
6
+ import cv2
7
+ import threading
8
+ import time
9
+
10
+
11
+
12
+ class CameraService:
13
+ def __init__(self):
14
+ self._cap = None
15
+ self._running = False
16
+ self._lock = threading.Lock()
17
+ self._frame = None
18
+
19
+ @property
20
+ def is_running(self) -> bool:
21
+ return self._running
22
+
23
+ def start(self, source: int = 0):
24
+ with self._lock:
25
+ if self._running:
26
+ return
27
+ self._cap = cv2.VideoCapture(source)
28
+ if not self._cap.isOpened():
29
+ raise RuntimeError("Cannot open camera")
30
+ self._running = True
31
+ threading.Thread(target=self._capture_loop, daemon=True).start()
32
+
33
+ def stop(self):
34
+ with self._lock:
35
+ self._running = False
36
+ if self._cap:
37
+ self._cap.release()
38
+ self._cap = None
39
+ self._frame = None
40
+
41
+ def _capture_loop(self):
42
+ while self._running:
43
+ if self._cap and self._cap.isOpened():
44
+ ok, frame = self._cap.read()
45
+ if ok:
46
+ self._frame = frame
47
+ time.sleep(0.033) # ~30 fps
48
+
49
+ def get_frame(self):
50
+ """Return the latest frame as JPEG bytes, or None."""
51
+ if self._frame is None:
52
+ return None
53
+ ok, buf = cv2.imencode(".jpg", self._frame)
54
+ return buf.tobytes() if ok else None
55
+
56
+ def get_frame_raw(self):
57
+ """Return raw numpy frame for processing."""
58
+ return self._frame.copy() if self._frame is not None else None
59
+
60
+ def generate_mjpeg(self):
61
+ """Generator for MJPEG streaming."""
62
+ while self._running:
63
+ jpeg = self.get_frame()
64
+ if jpeg:
65
+ yield (
66
+ b"--frame\r\n"
67
+ b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n"
68
+ )
69
+ time.sleep(0.066) # ~15 fps for streaming
70
+
71
+
72
+ # Singleton
73
+ camera = CameraService()
services/ocr_service.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Local OCR service disabled.
2
+ # All processing is handled by the remote Railway backend.
services/supabase_client.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from supabase import create_client, Client
3
+ from config import Config
4
+
5
+ def get_supabase() -> Client:
6
+ url: str = os.environ.get("SUPABASE_URL", "https://mymdiyxzrdtfdrgpgqow.supabase.co")
7
+ key: str = os.environ.get("SUPABASE_KEY", "your-supabase-key")
8
+ return create_client(url, key)
9
+
10
+ db = get_supabase()
static/assets/cctv_logo.png ADDED

Git LFS Details

  • SHA256: 5b76c73f6a66adb8e98d92a5bf3272b3a67869e29f7a11f142f5bfef9383022f
  • Pointer size: 131 Bytes
  • Size of remote file: 684 kB
static/css/style.css ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════════════
2
+ Ain El Aql β€” Dark CCTV-Style Dashboard
3
+ ═══════════════════════════════════════════════════════════ */
4
+
5
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
6
+
7
+ /* ── CSS Variables ─────────────────────────────────────────── */
8
+ :root {
9
+ --bg-primary: #0a0e17;
10
+ --bg-secondary: #111827;
11
+ --bg-card: #1a2236;
12
+ --bg-card-hover: #1f2a42;
13
+ --bg-input: #0d1321;
14
+ --border-color: #2a3654;
15
+ --border-glow: #3b82f6;
16
+ --text-primary: #e2e8f0;
17
+ --text-secondary: #94a3b8;
18
+ --text-muted: #64748b;
19
+ --accent-blue: #3b82f6;
20
+ --accent-cyan: #06b6d4;
21
+ --accent-green: #10b981;
22
+ --accent-red: #ef4444;
23
+ --accent-amber: #f59e0b;
24
+ --accent-purple: #8b5cf6;
25
+ --gradient-blue: linear-gradient(135deg, #3b82f6, #06b6d4);
26
+ --gradient-green: linear-gradient(135deg, #10b981, #34d399);
27
+ --gradient-red: linear-gradient(135deg, #ef4444, #f87171);
28
+ --gradient-purple: linear-gradient(135deg, #8b5cf6, #a78bfa);
29
+ --shadow-sm: 0 1px 3px rgba(0,0,0,.4);
30
+ --shadow-md: 0 4px 14px rgba(0,0,0,.5);
31
+ --shadow-lg: 0 10px 30px rgba(0,0,0,.6);
32
+ --shadow-glow-blue: 0 0 20px rgba(59,130,246,.15);
33
+ --shadow-glow-green: 0 0 20px rgba(16,185,129,.15);
34
+ --radius-sm: 8px;
35
+ --radius-md: 12px;
36
+ --radius-lg: 16px;
37
+ --radius-xl: 20px;
38
+ --transition: all .25s cubic-bezier(.4,0,.2,1);
39
+ }
40
+
41
+ /* ── Reset & Base ──────────────────────────────────────────── */
42
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
43
+ html { font-size: 15px; scroll-behavior: smooth; }
44
+ body {
45
+ font-family: 'Inter', system-ui, sans-serif;
46
+ background: var(--bg-primary);
47
+ color: var(--text-primary);
48
+ min-height: 100vh;
49
+ line-height: 1.6;
50
+ -webkit-font-smoothing: antialiased;
51
+ }
52
+ a { color: var(--accent-cyan); text-decoration: none; transition: var(--transition); }
53
+ a:hover { color: var(--accent-blue); }
54
+
55
+ /* ── Scrollbar ─────────────────────────────────────────────── */
56
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
57
+ ::-webkit-scrollbar-track { background: var(--bg-primary); }
58
+ ::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
59
+ ::-webkit-scrollbar-thumb:hover { background: var(--accent-blue); }
60
+
61
+ /* ── Layout ────────────────────────────────────────────────── */
62
+ .app-layout { display: flex; min-height: 100vh; }
63
+ .sidebar {
64
+ width: 260px; background: var(--bg-secondary);
65
+ border-right: 1px solid var(--border-color);
66
+ display: flex; flex-direction: column;
67
+ position: fixed; top: 0; left: 0; bottom: 0; z-index: 100;
68
+ transition: var(--transition);
69
+ }
70
+ .sidebar-header {
71
+ padding: 1.5rem; border-bottom: 1px solid var(--border-color);
72
+ display: flex; align-items: center; gap: .75rem;
73
+ }
74
+ .sidebar-logo {
75
+ width: 40px; height: 40px; border-radius: var(--radius-sm);
76
+ background: var(--gradient-blue); display: grid; place-items: center;
77
+ font-weight: 800; font-size: 1.1rem; color: #fff;
78
+ }
79
+ .sidebar-title { font-weight: 700; font-size: 1rem; }
80
+ .sidebar-subtitle { font-size: .7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .08em; }
81
+ .sidebar-nav { flex: 1; padding: 1rem .75rem; display: flex; flex-direction: column; gap: 4px; }
82
+ .nav-link {
83
+ display: flex; align-items: center; gap: .75rem;
84
+ padding: .7rem 1rem; border-radius: var(--radius-sm);
85
+ color: var(--text-secondary); font-size: .875rem; font-weight: 500;
86
+ transition: var(--transition); cursor: pointer; border: none; background: none; width: 100%; text-align: left;
87
+ }
88
+ .nav-link:hover { background: var(--bg-card); color: var(--text-primary); }
89
+ .nav-link.active { background: rgba(59,130,246,.12); color: var(--accent-blue); border-left: 3px solid var(--accent-blue); }
90
+ .nav-link .icon { width: 20px; text-align: center; font-size: 1rem; }
91
+ .sidebar-footer { padding: 1rem; border-top: 1px solid var(--border-color); }
92
+ .user-badge {
93
+ display: flex; align-items: center; gap: .75rem;
94
+ padding: .5rem .75rem; border-radius: var(--radius-sm);
95
+ background: var(--bg-card);
96
+ }
97
+ .user-avatar {
98
+ width: 36px; height: 36px; border-radius: 50%;
99
+ background: var(--gradient-purple); display: grid; place-items: center;
100
+ font-weight: 700; font-size: .8rem; color: #fff;
101
+ }
102
+ .user-name { font-size: .8rem; font-weight: 600; }
103
+ .user-role { font-size: .65rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; }
104
+ .main-content { flex: 1; margin-left: 260px; padding: 2rem; }
105
+ .page-header { margin-bottom: 2rem; }
106
+ .page-title { font-size: 1.75rem; font-weight: 800; letter-spacing: -.02em; }
107
+ .page-subtitle { color: var(--text-muted); font-size: .875rem; margin-top: .25rem; }
108
+
109
+ /* ── Cards ─────────────────────────────────────────────────── */
110
+ .card {
111
+ background: var(--bg-card); border: 1px solid var(--border-color);
112
+ border-radius: var(--radius-md); padding: 1.5rem;
113
+ transition: var(--transition);
114
+ }
115
+ .card:hover { border-color: rgba(59,130,246,.3); box-shadow: var(--shadow-glow-blue); }
116
+ .card-header {
117
+ display: flex; justify-content: space-between; align-items: center;
118
+ margin-bottom: 1rem; padding-bottom: .75rem; border-bottom: 1px solid var(--border-color);
119
+ }
120
+ .card-title { font-size: 1rem; font-weight: 700; }
121
+ .card-badge {
122
+ padding: .2rem .6rem; border-radius: 50px; font-size: .65rem;
123
+ font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
124
+ }
125
+ .badge-live { background: rgba(239,68,68,.15); color: var(--accent-red); animation: pulse-badge 2s infinite; }
126
+ .badge-ok { background: rgba(16,185,129,.15); color: var(--accent-green); }
127
+ .badge-warn { background: rgba(245,158,11,.15); color: var(--accent-amber); }
128
+ @keyframes pulse-badge { 0%,100% { opacity: 1; } 50% { opacity: .5; } }
129
+
130
+ /* ── Stat Cards ────────────────────────────────────────────── */
131
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
132
+ .stat-card {
133
+ background: var(--bg-card); border: 1px solid var(--border-color);
134
+ border-radius: var(--radius-md); padding: 1.25rem 1.5rem;
135
+ position: relative; overflow: hidden; transition: var(--transition);
136
+ }
137
+ .stat-card::before {
138
+ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
139
+ }
140
+ .stat-card.blue::before { background: var(--gradient-blue); }
141
+ .stat-card.green::before { background: var(--gradient-green); }
142
+ .stat-card.red::before { background: var(--gradient-red); }
143
+ .stat-card.purple::before { background: var(--gradient-purple); }
144
+ .stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
145
+ .stat-label { font-size: .75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; }
146
+ .stat-value { font-size: 2rem; font-weight: 800; margin: .25rem 0; font-family: 'JetBrains Mono', monospace; }
147
+ .stat-value.blue { color: var(--accent-blue); }
148
+ .stat-value.green { color: var(--accent-green); }
149
+ .stat-value.red { color: var(--accent-red); }
150
+ .stat-value.purple { color: var(--accent-purple); }
151
+ .stat-detail { font-size: .75rem; color: var(--text-secondary); }
152
+
153
+ /* ── Tables ────────────────────────────────────────────────── */
154
+ .table-wrapper { overflow-x: auto; border-radius: var(--radius-md); border: 1px solid var(--border-color); }
155
+ table { width: 100%; border-collapse: collapse; }
156
+ thead { background: rgba(59,130,246,.06); }
157
+ th {
158
+ padding: .75rem 1rem; text-align: left; font-size: .7rem;
159
+ text-transform: uppercase; letter-spacing: .06em;
160
+ color: var(--text-muted); font-weight: 600; border-bottom: 1px solid var(--border-color);
161
+ }
162
+ td {
163
+ padding: .75rem 1rem; font-size: .85rem; border-bottom: 1px solid rgba(42,54,84,.5);
164
+ color: var(--text-secondary);
165
+ }
166
+ tr:hover td { background: rgba(59,130,246,.04); color: var(--text-primary); }
167
+ .mono { font-family: 'JetBrains Mono', monospace; font-size: .8rem; }
168
+
169
+ /* ── Buttons ───────────────────────────────────────────────── */
170
+ .btn {
171
+ display: inline-flex; align-items: center; gap: .5rem;
172
+ padding: .6rem 1.25rem; border-radius: var(--radius-sm);
173
+ font-size: .85rem; font-weight: 600; cursor: pointer;
174
+ border: 1px solid transparent; transition: var(--transition);
175
+ font-family: 'Inter', sans-serif;
176
+ }
177
+ .btn-primary { background: var(--gradient-blue); color: #fff; }
178
+ .btn-primary:hover { box-shadow: 0 4px 20px rgba(59,130,246,.35); transform: translateY(-1px); }
179
+ .btn-success { background: var(--gradient-green); color: #fff; }
180
+ .btn-success:hover { box-shadow: 0 4px 20px rgba(16,185,129,.35); transform: translateY(-1px); }
181
+ .btn-danger { background: var(--gradient-red); color: #fff; }
182
+ .btn-danger:hover { box-shadow: 0 4px 20px rgba(239,68,68,.35); transform: translateY(-1px); }
183
+ .btn-outline {
184
+ background: transparent; border-color: var(--border-color); color: var(--text-secondary);
185
+ }
186
+ .btn-outline:hover { border-color: var(--accent-blue); color: var(--accent-blue); background: rgba(59,130,246,.06); }
187
+ .btn-sm { padding: .4rem .8rem; font-size: .75rem; }
188
+ .btn-icon { width: 36px; height: 36px; padding: 0; display: grid; place-items: center; border-radius: var(--radius-sm); }
189
+
190
+ /* ── Forms ──────────────────────────────────────────────────── */
191
+ .form-group { margin-bottom: 1.25rem; }
192
+ .form-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text-secondary); margin-bottom: .4rem; }
193
+ .form-input, .form-select {
194
+ width: 100%; padding: .7rem 1rem; background: var(--bg-input);
195
+ border: 1px solid var(--border-color); border-radius: var(--radius-sm);
196
+ color: var(--text-primary); font-size: .875rem; font-family: 'Inter', sans-serif;
197
+ transition: var(--transition);
198
+ }
199
+ .form-input:focus, .form-select:focus {
200
+ outline: none; border-color: var(--accent-blue);
201
+ box-shadow: 0 0 0 3px rgba(59,130,246,.15);
202
+ }
203
+ .form-input::placeholder { color: var(--text-muted); }
204
+
205
+ /* ── Alerts ────────────────────────────────────────────────── */
206
+ .alert {
207
+ padding: .85rem 1.25rem; border-radius: var(--radius-sm);
208
+ font-size: .85rem; margin-bottom: 1rem; display: flex; align-items: center; gap: .5rem;
209
+ border: 1px solid;
210
+ }
211
+ .alert-error { background: rgba(239,68,68,.1); border-color: rgba(239,68,68,.25); color: #fca5a5; }
212
+ .alert-success { background: rgba(16,185,129,.1); border-color: rgba(16,185,129,.25); color: #6ee7b7; }
213
+ .alert-info { background: rgba(59,130,246,.1); border-color: rgba(59,130,246,.25); color: #93c5fd; }
214
+ .alert-warning { background: rgba(245,158,11,.1); border-color: rgba(245,158,11,.25); color: #fcd34d; }
215
+
216
+ /* ── CCTV Camera Feed ──────────────────────────────────────── */
217
+ .camera-feed {
218
+ position: relative; background: #000; border-radius: var(--radius-md);
219
+ overflow: hidden; border: 2px solid var(--border-color);
220
+ aspect-ratio: 16/9;
221
+ }
222
+ .camera-feed img, .camera-feed video {
223
+ width: 100%; height: 100%; object-fit: cover;
224
+ }
225
+ .camera-overlay {
226
+ position: absolute; top: 0; left: 0; right: 0;
227
+ padding: .5rem .75rem;
228
+ background: linear-gradient(180deg, rgba(0,0,0,.7), transparent);
229
+ display: flex; justify-content: space-between; align-items: center;
230
+ }
231
+ .camera-overlay .rec {
232
+ display: flex; align-items: center; gap: .4rem;
233
+ font-size: .7rem; font-weight: 700; color: var(--accent-red);
234
+ text-transform: uppercase; letter-spacing: .08em;
235
+ }
236
+ .camera-overlay .rec::before {
237
+ content: ''; width: 8px; height: 8px; background: var(--accent-red);
238
+ border-radius: 50%; animation: pulse-badge 1.5s infinite;
239
+ }
240
+ .camera-overlay .timestamp {
241
+ font-family: 'JetBrains Mono', monospace; font-size: .7rem; color: rgba(255,255,255,.7);
242
+ }
243
+ .camera-controls {
244
+ display: flex; gap: .5rem; margin-top: .75rem; flex-wrap: wrap;
245
+ }
246
+ .camera-placeholder {
247
+ display: flex; flex-direction: column; align-items: center;
248
+ justify-content: center; height: 100%; color: var(--text-muted);
249
+ font-size: .85rem; gap: .5rem;
250
+ }
251
+ .camera-placeholder .icon { font-size: 3rem; opacity: .3; }
252
+
253
+ /* ── OCR Result Panel ──────────────────────────────────────── */
254
+ .ocr-result {
255
+ border: 1px solid var(--border-color); border-radius: var(--radius-md);
256
+ overflow: hidden; background: var(--bg-card);
257
+ }
258
+ .ocr-result-header {
259
+ padding: 1rem 1.5rem; display: flex; justify-content: space-between;
260
+ align-items: center; border-bottom: 1px solid var(--border-color);
261
+ }
262
+ .ocr-plate {
263
+ font-family: 'JetBrains Mono', monospace; font-size: 1.75rem;
264
+ font-weight: 800; letter-spacing: .05em;
265
+ }
266
+ .ocr-result-body { padding: 1.25rem 1.5rem; }
267
+ .ocr-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
268
+ .ocr-field-label { font-size: .7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: .2rem; }
269
+ .ocr-field-value { font-size: .9rem; font-weight: 600; }
270
+ .ocr-image { border-radius: var(--radius-sm); max-width: 100%; margin-top: 1rem; border: 1px solid var(--border-color); }
271
+
272
+ /* ── Status indicators ─────────────────────────────────────── */
273
+ .status { display: inline-flex; align-items: center; gap: .35rem; font-size: .8rem; font-weight: 600; }
274
+ .status::before { content: ''; width: 8px; height: 8px; border-radius: 50%; }
275
+ .status-registered { color: var(--accent-green); }
276
+ .status-registered::before { background: var(--accent-green); box-shadow: 0 0 8px rgba(16,185,129,.5); }
277
+ .status-unregistered { color: var(--accent-red); }
278
+ .status-unregistered::before { background: var(--accent-red); box-shadow: 0 0 8px rgba(239,68,68,.5); }
279
+ .status-active { color: var(--accent-cyan); }
280
+ .status-active::before { background: var(--accent-cyan); box-shadow: 0 0 8px rgba(6,182,212,.5); }
281
+
282
+ /* ── Gate Status ───────────────────────────────────────────── */
283
+ .gate-status {
284
+ display: flex; align-items: center; gap: 1rem;
285
+ padding: 1rem 1.5rem; border-radius: var(--radius-md);
286
+ font-weight: 700; font-size: 1rem;
287
+ }
288
+ .gate-open { background: rgba(16,185,129,.1); border: 1px solid rgba(16,185,129,.3); color: var(--accent-green); }
289
+ .gate-closed { background: rgba(239,68,68,.1); border: 1px solid rgba(239,68,68,.3); color: var(--accent-red); }
290
+ .gate-icon { font-size: 1.5rem; }
291
+
292
+ /* ── Auth Pages ────────────────────────────────────────────── */
293
+ .auth-wrapper {
294
+ min-height: 100vh; display: grid; place-items: center;
295
+ background: var(--bg-primary);
296
+ background-image: radial-gradient(ellipse at 20% 50%, rgba(59,130,246,.08) 0%, transparent 50%),
297
+ radial-gradient(ellipse at 80% 20%, rgba(139,92,246,.06) 0%, transparent 50%);
298
+ }
299
+ .auth-card {
300
+ width: 100%; max-width: 420px; padding: 2.5rem;
301
+ background: var(--bg-card); border: 1px solid var(--border-color);
302
+ border-radius: var(--radius-xl); box-shadow: var(--shadow-lg);
303
+ }
304
+ .auth-logo { text-align: center; margin-bottom: 2rem; }
305
+ .auth-logo-icon {
306
+ width: 64px; height: 64px; margin: 0 auto .75rem;
307
+ background: var(--gradient-blue); border-radius: var(--radius-md);
308
+ display: grid; place-items: center; font-size: 1.75rem; color: #fff; font-weight: 800;
309
+ }
310
+ .auth-logo h1 { font-size: 1.5rem; font-weight: 800; }
311
+ .auth-logo p { font-size: .8rem; color: var(--text-muted); margin-top: .25rem; }
312
+ .auth-footer { text-align: center; margin-top: 1.5rem; font-size: .8rem; color: var(--text-muted); }
313
+
314
+ /* ── Grid layouts ──────────────────────────────────────────── */
315
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
316
+ .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
317
+ .content-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem; }
318
+
319
+ /* ── Modal ──────────────────────────────────────────────────── */
320
+ .modal-overlay {
321
+ position: fixed; inset: 0; background: rgba(0,0,0,.65);
322
+ backdrop-filter: blur(4px); display: none; place-items: center; z-index: 1000;
323
+ }
324
+ .modal-overlay.active { display: grid; }
325
+ .modal {
326
+ background: var(--bg-card); border: 1px solid var(--border-color);
327
+ border-radius: var(--radius-lg); padding: 2rem; width: 90%; max-width: 500px;
328
+ box-shadow: var(--shadow-lg);
329
+ }
330
+ .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem; }
331
+ .modal-title { font-size: 1.1rem; font-weight: 700; }
332
+ .modal-close { background: none; border: none; color: var(--text-muted); font-size: 1.25rem; cursor: pointer; }
333
+ .modal-close:hover { color: var(--text-primary); }
334
+ .modal-actions { display: flex; gap: .75rem; justify-content: flex-end; margin-top: 1.5rem; }
335
+
336
+ /* ── Loading ───────────────────────────────────────────────── */
337
+ .spinner {
338
+ width: 32px; height: 32px; border: 3px solid var(--border-color);
339
+ border-top-color: var(--accent-blue); border-radius: 50%;
340
+ animation: spin .8s linear infinite;
341
+ }
342
+ @keyframes spin { to { transform: rotate(360deg); } }
343
+ .loading-overlay {
344
+ position: absolute; inset: 0; background: rgba(10,14,23,.8);
345
+ display: none; place-items: center; z-index: 10; border-radius: var(--radius-md);
346
+ }
347
+ .loading-overlay.active { display: grid; }
348
+
349
+ /* ── Animations ────────────────────────────────────────────── */
350
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
351
+ .fade-in { animation: fadeIn .4s ease-out; }
352
+ @keyframes slideIn { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } }
353
+
354
+ /* ── Responsive ────────────────────────────────────────────── */
355
+ @media (max-width: 1024px) {
356
+ .sidebar { width: 220px; }
357
+ .main-content { margin-left: 220px; }
358
+ .grid-3 { grid-template-columns: 1fr 1fr; }
359
+ }
360
+ @media (max-width: 768px) {
361
+ .sidebar { display: none; }
362
+ .main-content { margin-left: 0; padding: 1rem; }
363
+ .grid-2, .grid-3, .content-grid { grid-template-columns: 1fr; }
364
+ .stats-grid { grid-template-columns: 1fr 1fr; }
365
+ .ocr-grid { grid-template-columns: 1fr; }
366
+ }
367
+
368
+ /* ── Utility ───────────────────────────────────────────────── */
369
+ .mt-1 { margin-top: .5rem; } .mt-2 { margin-top: 1rem; } .mt-3 { margin-top: 1.5rem; }
370
+ .mb-1 { margin-bottom: .5rem; } .mb-2 { margin-bottom: 1rem; } .mb-3 { margin-bottom: 1.5rem; }
371
+ .text-center { text-align: center; }
372
+ .text-right { text-align: right; }
373
+ .flex { display: flex; } .flex-between { display: flex; justify-content: space-between; align-items: center; }
374
+ .gap-1 { gap: .5rem; } .gap-2 { gap: 1rem; }
375
+ .hidden { display: none !important; }
376
+ .w-full { width: 100%; }
377
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
378
+
379
+ /* ── Premium UI Overhaul (Uber-Professional) ────────────────── */
380
+ .glass-card {
381
+ background: rgba(26, 34, 54, 0.75);
382
+ backdrop-filter: blur(12px);
383
+ border: 1px solid rgba(255, 255, 255, 0.1);
384
+ border-radius: 16px;
385
+ padding: 1.5rem;
386
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
387
+ }
388
+ .pulse-badge {
389
+ display: flex; align-items: center; gap: 8px;
390
+ background: rgba(239, 68, 68, 0.12); color: #f87171;
391
+ padding: 6px 12px; border-radius: 20px; font-size: 0.7rem; font-weight: 700;
392
+ text-transform: uppercase; letter-spacing: 1px;
393
+ }
394
+ .pulse-dot {
395
+ width: 8px; height: 8px; background: #ef4444; border-radius: 50%;
396
+ animation: pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
397
+ }
398
+ @keyframes pulse-ring {
399
+ 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
400
+ 70% { transform: scale(1); box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
401
+ 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
402
+ }
403
+ #gateOverlay {
404
+ position: fixed; inset: 0; background: rgba(10, 14, 23, 0.96);
405
+ z-index: 10000; display: none; flex-direction: column;
406
+ align-items: center; justify-content: center; backdrop-filter: blur(20px);
407
+ }
408
+ .gate-timer-circle {
409
+ width: 220px; height: 220px; border: 10px solid #10b981;
410
+ border-radius: 50%; display: grid; place-items: center;
411
+ font-size: 6.5rem; font-weight: 900; color: #10b981;
412
+ box-shadow: 0 0 60px rgba(16, 185, 129, 0.3); margin-bottom: 2rem;
413
+ }
414
+ .status-pill { padding: 4px 12px; border-radius: 6px; font-size: 0.7rem; font-weight: 800; text-transform: uppercase; }
415
+ .status-pill.guest { background: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.3); }
416
+ .status-pill.reg { background: rgba(16, 185, 129, 0.15); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.3); }
417
+ .duration-timer { font-family: 'JetBrains Mono', monospace; font-weight: 700; color: #3b82f6; background: rgba(59, 130, 246, 0.1); padding: 2px 6px; border-radius: 4px; }
418
+ .plate-display { font-family: 'JetBrains Mono', monospace; font-size: 2.5rem; font-weight: 800; letter-spacing: 2px; color: white; }
templates/admin/dashboard.html ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Admin Dashboard β€” Ain El Aql{% endblock %}
3
+ {% block body %}
4
+ <div class="app-layout">
5
+ {% with role='admin', active='dashboard' %}
6
+ {% include "partials/sidebar.html" %}
7
+ {% endwith %}
8
+
9
+ <main class="main-content">
10
+ <div class="page-header fade-in flex-between">
11
+ <div>
12
+ <h1 class="page-title">Admin Dashboard</h1>
13
+ <p class="page-subtitle">Parking facility management & analytics</p>
14
+ </div>
15
+ <div class="pulse-badge">
16
+ <div class="pulse-dot"></div>
17
+ System Live
18
+ </div>
19
+ </div>
20
+
21
+ <div class="stats-grid fade-in" id="statsGrid">
22
+ <div class="stat-card blue">
23
+ <div class="stat-label">Total Capacity</div>
24
+ <div class="stat-value blue" id="statCapacity">β€”</div>
25
+ </div>
26
+ <div class="stat-card green">
27
+ <div class="stat-label">Currently Inside</div>
28
+ <div class="stat-value green" id="statInside">β€”</div>
29
+ </div>
30
+ <div class="stat-card purple">
31
+ <div class="stat-label">Available Spots</div>
32
+ <div class="stat-value purple" id="statAvailable">β€”</div>
33
+ </div>
34
+ <div class="stat-card red">
35
+ <div class="stat-label">Occupancy Rate</div>
36
+ <div class="stat-value red" id="statOccRate">β€”</div>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="content-grid" style="grid-template-columns: 1fr;">
41
+ <!-- Inside Cars -->
42
+ <div class="card fade-in">
43
+ <div class="card-header">
44
+ <span class="card-title">πŸš— Vehicles Inside</span>
45
+ <span class="card-badge badge-live">LIVE</span>
46
+ </div>
47
+ <div class="table-wrapper" style="max-height:500px;overflow-y:auto;">
48
+ <table>
49
+ <thead>
50
+ <tr>
51
+ <th>Plate</th>
52
+ <th>Registration</th>
53
+ <th>Entry</th>
54
+ <th>Duration</th>
55
+ <th>Est. Fee</th>
56
+ <th>Status</th>
57
+ <th>Payment</th>
58
+ </tr>
59
+ </thead>
60
+ <tbody id="insideBody">
61
+ <tr><td colspan="7" class="text-center" style="padding:2rem;color:var(--text-muted);">Loading live data...</td></tr></tbody>
62
+ </table>
63
+ </div>
64
+ </div>
65
+ </div>
66
+
67
+ <!-- Quick Admin Actions -->
68
+ <div class="card fade-in">
69
+ <div class="card-header">
70
+ <span class="card-title">⚑ Administration</span>
71
+ </div>
72
+ <div style="display:flex;gap:.75rem;flex-wrap:wrap;">
73
+ <a href="{{ url_for('admin.garage') }}" class="btn btn-outline" style="background:var(--accent-blue);color:white;border:none;">
74
+ <span class="icon">πŸ—οΈ</span> Garage Status
75
+ </a>
76
+ <a href="{{ url_for('admin.users') }}" class="btn btn-outline">
77
+ <span class="icon">πŸ‘₯</span> Manage Users
78
+ </a>
79
+ <a href="{{ url_for('admin.vehicles') }}" class="btn btn-outline">
80
+ <span class="icon">πŸš—</span> Manage Vehicles
81
+ </a>
82
+ <a href="{{ url_for('admin.history') }}" class="btn btn-outline">
83
+ <span class="icon">πŸ“‹</span> Parking History
84
+ </a>
85
+ <button class="btn btn-danger" onclick="runMaintenance()"><span>🧹</span> Refresh Storage (Photos)</button>
86
+ </div>
87
+ </div>
88
+ </main>
89
+ </div>
90
+ {% endblock %}
91
+
92
+ {% block scripts %}
93
+ <script>
94
+ function updateStats(occ) {
95
+ if (!occ) return;
96
+ const g = occ.garage || occ;
97
+ const cap = g.total_capacity || g.capacity || 0;
98
+ const inside = g.inside || g.occupied || g.current_count || 0;
99
+ document.getElementById('statCapacity').textContent = cap || 'β€”';
100
+ document.getElementById('statInside').textContent = inside;
101
+ document.getElementById('statAvailable').textContent = cap ? cap - inside : 'β€”';
102
+ document.getElementById('statOccRate').textContent = cap ? Math.round((inside/cap)*100) + '%' : 'β€”';
103
+ }
104
+
105
+ function formatDuration(mins) {
106
+ if (mins == null) return 'β€”';
107
+ const h = Math.floor(mins / 60);
108
+ const m = Math.floor(mins % 60);
109
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
110
+ }
111
+
112
+ function renderInside(data) {
113
+ const body = document.getElementById('insideBody');
114
+ // Backend returns { items: [...] } where each item has { session, vehicle, pricing, payment_status }
115
+ let items = Array.isArray(data) ? data : (data.items || data.data || data.cars || []);
116
+ if (!items.length) { body.innerHTML = '<tr><td colspan="6" class="text-center" style="padding:2rem;color:var(--text-muted);">No vehicles inside</td></tr>'; return; }
117
+ body.innerHTML = items.map(item => {
118
+ const s = item.session || item;
119
+ const v = item.vehicle || {};
120
+ const p = item.pricing || {};
121
+ const ps = item.payment_status || {};
122
+
123
+ const plate = s.plate_arabic || s.plate_english || s.plate_key || v.plate_letters_ar || 'β€”';
124
+ const entry = s.check_in_at ? new Date(s.check_in_at).toLocaleString() : (s.entry_time ? new Date(s.entry_time).toLocaleString() : 'β€”');
125
+ const duration = formatDuration(p.duration_minutes);
126
+ const fee = p.total_fee_egp != null ? p.total_fee_egp + ' EGP' : 'β€”';
127
+ const status = s.status || 'entered';
128
+ const statusClass = status === 'paid' ? 'status-registered' : 'status-active';
129
+ const statusLabel = status === 'paid' ? 'βœ… Paid' : status === 'entered' ? 'πŸ”΅ Inside' : status;
130
+
131
+ // Payment method badge
132
+ const notes = (s.notes || '').toLowerCase();
133
+ const hasCashIntent = notes.includes('intent_cash') || notes.includes('cash');
134
+ const isGuest = !v.id && !v.plate_letters_ar;
135
+
136
+ let registration = isGuest
137
+ ? '<span style="color:var(--accent-red);font-size:0.8rem;font-weight:bold;">Unregistered</span>'
138
+ : '<span style="color:var(--accent-green);font-size:0.8rem;font-weight:bold;">Registered</span>';
139
+
140
+ let isCash = isGuest || hasCashIntent;
141
+ let isPaidVisa = s.payment_confirmed_at != null && !isCash;
142
+
143
+ let payBadge = 'β€”';
144
+ if (isPaidVisa) payBadge = '<span style="color:var(--accent-blue);">πŸ’³ Visa</span>';
145
+ else if (isCash) payBadge = '<span style="color:var(--accent-green);">πŸ’΅ Cash</span>';
146
+
147
+ return `<tr>
148
+ <td class="mono">${plate}</td>
149
+ <td>${registration}</td>
150
+ <td>${entry}</td>
151
+ <td>${duration}</td><td>${fee}</td>
152
+ <td><span class="status ${statusClass}">${statusLabel}</span></td>
153
+ <td>${payBadge}</td>
154
+ </tr>`;
155
+ }).join('');
156
+ }
157
+
158
+
159
+
160
+ async function refreshData() {
161
+ try {
162
+ const [oR, iR] = await Promise.all([
163
+ fetch('/admin/api/occupancy'), fetch('/admin/api/inside-cars')
164
+ ]);
165
+ updateStats(await oR.json()); renderInside(await iR.json());
166
+ } catch(e) { console.error(e); }
167
+ }
168
+
169
+ async function runMaintenance() {
170
+ if (!confirm('Clear database photo storage? (Profiles and tables will not be affected)')) return;
171
+ try {
172
+ const r = await fetch('/admin/api/maintenance', {method:'POST'});
173
+ const d = await r.json();
174
+ alert(d.message || d.error || 'Done');
175
+ } catch(e) { alert('Error: ' + e); }
176
+ }
177
+
178
+ refreshData();
179
+ setInterval(refreshData, 2000); // 2 seconds for high-tier real-time feel
180
+ </script>
181
+ {% endblock %}
templates/admin/garage.html ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Garage Status β€” Ain El Aql{% endblock %}
3
+ {% block body %}
4
+ <div class="app-layout">
5
+ {% with role='admin', active='garage' %}
6
+ {% include "partials/sidebar.html" %}
7
+ {% endwith %}
8
+
9
+ <main class="main-content">
10
+ <div class="page-header fade-in flex-between">
11
+ <div>
12
+ <h1 class="page-title">Garage Status</h1>
13
+ <p class="page-subtitle">Live view of all parking operations</p>
14
+ </div>
15
+ <button class="btn btn-outline" onclick="refreshAll()"><span>πŸ”„</span> Refresh</button>
16
+ </div>
17
+
18
+ <div class="stats-grid fade-in">
19
+ <div class="stat-card green">
20
+ <div class="stat-label">Entered Today</div>
21
+ <div class="stat-value green" id="sEntered">β€”</div>
22
+ </div>
23
+ <div class="stat-card blue">
24
+ <div class="stat-label">Currently Inside</div>
25
+ <div class="stat-value blue" id="sInside">β€”</div>
26
+ </div>
27
+ <div class="stat-card purple">
28
+ <div class="stat-label">Left Today</div>
29
+ <div class="stat-value purple" id="sLeft">β€”</div>
30
+ </div>
31
+ <div class="stat-card red">
32
+ <div class="stat-label">Leaving Soon</div>
33
+ <div class="stat-value red" id="sLeaving">β€”</div>
34
+ </div>
35
+ </div>
36
+
37
+ <!-- Tabs -->
38
+ <div class="card fade-in">
39
+ <div style="display:flex;gap:.5rem;margin-bottom:1rem;border-bottom:1px solid var(--border-color);padding-bottom:.75rem;">
40
+ <button class="btn btn-sm btn-primary" onclick="showTab('inside')" id="tabInside">Inside</button>
41
+ <button class="btn btn-sm btn-outline" onclick="showTab('entered')" id="tabEntered">Entered</button>
42
+ <button class="btn btn-sm btn-outline" onclick="showTab('left')" id="tabLeft">Left</button>
43
+ <button class="btn btn-sm btn-outline" onclick="showTab('leaving')" id="tabLeaving">Leaving Soon</button>
44
+ </div>
45
+ <div class="table-wrapper" style="max-height:500px;overflow-y:auto;">
46
+ <table>
47
+ <thead>
48
+ <tr>
49
+ <th>Plate</th>
50
+ <th>Registration</th>
51
+ <th>Entry Time</th>
52
+ <th>Exit Time</th>
53
+ <th>Duration</th>
54
+ <th>Fee</th>
55
+ <th>Status</th>
56
+ <th>Payment</th>
57
+ </tr>
58
+ </thead>
59
+ <tbody id="tableBody">
60
+ <tr><td colspan="8" class="text-center" style="padding:2rem;color:var(--text-muted);">Loading data...</td></tr></tbody>
61
+ </table>
62
+ </div>
63
+ </div>
64
+ </main>
65
+ </div>
66
+ {% endblock %}
67
+
68
+ {% block scripts %}
69
+ <script>
70
+ let cacheData = { inside:[], entered:[], left:[], leaving:[] };
71
+ let currentTab = 'inside';
72
+
73
+ function showTab(tab) {
74
+ currentTab = tab;
75
+ ['inside','entered','left','leaving'].forEach(t => {
76
+ document.getElementById('tab'+t.charAt(0).toUpperCase()+t.slice(1)).className = t===tab ? 'btn btn-sm btn-primary' : 'btn btn-sm btn-outline';
77
+ });
78
+ renderTable(cacheData[tab]);
79
+ }
80
+
81
+ function extractItems(data) {
82
+ if (!data) return [];
83
+ if (Array.isArray(data)) return data;
84
+ return data.items || data.history || data.data || data.cars || data.sessions || [];
85
+ }
86
+
87
+ function normalizePlate(p) {
88
+ if (!p) return '';
89
+ const map = {'Ω ':'0','Ω‘':'1','Ω’':'2','Ω£':'3','Ω€':'4','Ω₯':'5','Ω¦':'6','Ω§':'7','Ω¨':'8','Ω©':'9'};
90
+ return String(p).replace(/[Ω -Ω©]/g, d => map[d]).replace(/[\s\-\|]/g, '').trim();
91
+ }
92
+
93
+ function formatDuration(mins) {
94
+ if (mins == null) return 'β€”';
95
+ const h = Math.floor(mins / 60);
96
+ const m = Math.floor(mins % 60);
97
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
98
+ }
99
+
100
+ function renderTable(items) {
101
+ const body = document.getElementById('tableBody');
102
+ if (!items || !items.length) {
103
+ body.innerHTML = '<tr><td colspan="8" class="text-center" style="padding:2rem;color:var(--text-muted);">No records</td></tr>';
104
+ return;
105
+ }
106
+ body.innerHTML = items.map(item => {
107
+ const s = item.session || item;
108
+ const v = item.vehicle || {};
109
+ const p = item.pricing || {};
110
+ const ps = item.payment_status || {};
111
+
112
+ const plate = s.plate_arabic || s.plate_english || s.plate_key ||
113
+ ((v.plate_letters_ar||'') + (v.plate_numbers_ar||'')) || 'β€”';
114
+ const entry = s.check_in_at ? new Date(s.check_in_at).toLocaleString() : 'β€”';
115
+ const exit = s.check_out_at ? new Date(s.check_out_at).toLocaleString() : 'β€”';
116
+ const duration = formatDuration(p.duration_minutes);
117
+ const fee = p.total_fee_egp != null ? p.total_fee_egp + ' EGP' : 'β€”';
118
+
119
+ const status = s.status || (s.check_out_at ? 'exited' : 'entered');
120
+ let stLabel, stClass;
121
+ if (status === 'exited' || s.check_out_at) { stLabel = 'Left'; stClass = 'status-unregistered'; }
122
+ else if (status === 'paid') { stLabel = 'Paid'; stClass = 'status-registered'; }
123
+ else { stLabel = 'Inside'; stClass = 'status-active'; }
124
+
125
+ // Payment badge
126
+ const notes = (s.notes || '').toLowerCase();
127
+ const hasCashIntent = notes.includes('intent_cash') || notes.includes('cash');
128
+ const isGuest = !v.id && !v.plate_letters_ar;
129
+
130
+ let registration = isGuest
131
+ ? '<span style="color:var(--accent-red);font-size:0.8rem;font-weight:bold;">Unregistered</span>'
132
+ : '<span style="color:var(--accent-green);font-size:0.8rem;font-weight:bold;">Registered</span>';
133
+
134
+ let isCash = isGuest || hasCashIntent;
135
+ let isPaidVisa = s.payment_confirmed_at != null && !isCash;
136
+
137
+ let payBadge = 'β€”';
138
+ if (isPaidVisa) payBadge = '<span style="color:var(--accent-blue);">πŸ’³ Visa</span>';
139
+ else if (isCash) payBadge = '<span style="color:var(--accent-green);">πŸ’΅ Cash</span>';
140
+
141
+ return `<tr>
142
+ <td class="mono">${plate}</td>
143
+ <td>${registration}</td>
144
+ <td>${entry}</td><td>${exit}</td>
145
+ <td>${duration}</td><td>${fee}</td>
146
+ <td><span class="status ${stClass}">${stLabel}</span></td>
147
+ <td>${payBadge}</td>
148
+ </tr>`;
149
+ }).join('');
150
+ }
151
+
152
+ async function refreshAll() {
153
+ try {
154
+ const [iR, eR, lR, lvR] = await Promise.all([
155
+ fetch('/admin/api/inside-cars'), fetch('/admin/api/entered-cars'),
156
+ fetch('/admin/api/left-cars'), fetch('/admin/api/leaving-soon')
157
+ ]);
158
+ const rawInside = extractItems(await iR.json());
159
+ const rawEntered = extractItems(await eR.json());
160
+ const rawLeft = extractItems(await lR.json());
161
+ const rawLeaving = extractItems(await lvR.json());
162
+
163
+ const leftPlates = new Set(rawLeft.map(item => {
164
+ const s = item.session || item;
165
+ return normalizePlate(s.plate_arabic || s.plate_english || s.plate_key);
166
+ }).filter(Boolean));
167
+
168
+ cacheData.inside = rawInside.filter(item => {
169
+ const s = item.session || item;
170
+ const plate = normalizePlate(s.plate_arabic || s.plate_english || s.plate_key);
171
+ return !leftPlates.has(plate);
172
+ });
173
+
174
+ cacheData.entered = rawEntered;
175
+ cacheData.left = rawLeft;
176
+ cacheData.leaving = rawLeaving;
177
+
178
+ document.getElementById('sInside').textContent = cacheData.inside.length;
179
+ document.getElementById('sEntered').textContent = cacheData.entered.length;
180
+ document.getElementById('sLeft').textContent = cacheData.left.length;
181
+ document.getElementById('sLeaving').textContent = cacheData.leaving.length;
182
+ renderTable(cacheData[currentTab]);
183
+ } catch(e) { console.error(e); }
184
+ }
185
+
186
+ refreshAll();
187
+ setInterval(refreshAll, 2000); // 2 seconds for high-tier real-time feel
188
+ </script>
189
+ {% endblock %}
templates/admin/history.html ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Parking History β€” Ain El Aql{% endblock %}
3
+ {% block body %}
4
+ <div class="app-layout">
5
+ {% with role='admin', active='history' %}
6
+ {% include "partials/sidebar.html" %}
7
+ {% endwith %}
8
+
9
+ <main class="main-content">
10
+ <div class="page-header fade-in flex-between">
11
+ <div>
12
+ <h1 class="page-title">Parking History</h1>
13
+ <p class="page-subtitle">Complete ledger of all parking sessions</p>
14
+ </div>
15
+ <button class="btn btn-outline" onclick="loadHistory()"><span>πŸ”„</span> Refresh</button>
16
+ </div>
17
+
18
+ <div class="card fade-in">
19
+ <div class="table-wrapper" style="max-height:600px;overflow-y:auto;">
20
+ <table>
21
+ <thead>
22
+ <tr>
23
+ <th>Plate</th>
24
+ <th>Owner / User</th>
25
+ <th>Entry Time</th>
26
+ <th>Exit Time</th>
27
+ <th>Duration</th>
28
+ <th>Fee</th>
29
+ <th>Payment</th>
30
+ <th>Status</th>
31
+ </tr>
32
+ </thead>
33
+ <tbody id="histBody">
34
+ <tr><td colspan="8" class="text-center" style="padding:2rem;color:var(--text-muted);">Loading history...</td></tr>
35
+ </tbody>
36
+ </table>
37
+ </div>
38
+ </div>
39
+ </main>
40
+ </div>
41
+ {% endblock %}
42
+
43
+ {% block scripts %}
44
+ <script>
45
+ function formatDuration(mins) {
46
+ if (mins == null) return 'β€”';
47
+ const h = Math.floor(mins / 60);
48
+ const m = Math.floor(mins % 60);
49
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
50
+ }
51
+
52
+ async function loadHistory() {
53
+ try {
54
+ const r = await fetch('/admin/api/history');
55
+ const data = await r.json();
56
+ // Backend returns { history: [...] } where each item has { session, vehicle, pricing, payment_status, events }
57
+ let items = Array.isArray(data) ? data : (data.history || data.items || data.data || data.sessions || []);
58
+ const body = document.getElementById('histBody');
59
+ if (!items.length) {
60
+ body.innerHTML = '<tr><td colspan="9" class="text-center" style="padding:2rem;color:var(--text-muted);">No history records</td></tr>';
61
+ return;
62
+ }
63
+ body.innerHTML = items.map(item => {
64
+ const s = item.session || item;
65
+ const v = item.vehicle || {};
66
+ const p = item.pricing || {};
67
+ const ps = item.payment_status || {};
68
+
69
+ const plate = s.plate_arabic || s.plate_english || s.plate_key ||
70
+ ((v.plate_letters_ar||'') + (v.plate_numbers_ar||'')) || 'β€”';
71
+
72
+ // Attempt to extract username
73
+ let ownerName = 'β€”';
74
+ if (v.owner) {
75
+ ownerName = v.owner.username || v.owner.full_name || v.owner.first_name || 'β€”';
76
+ } else if (v.owner_id) {
77
+ ownerName = 'Registered';
78
+ }
79
+
80
+ const isGuest = !v.id && !v.plate_letters_ar;
81
+ if (isGuest) {
82
+ ownerName = '<span class="status status-unregistered" style="font-size:0.7rem;padding:2px 6px;">Unregistered</span>';
83
+ }
84
+
85
+ const entry = s.check_in_at ? new Date(s.check_in_at).toLocaleString() : 'β€”';
86
+ const exit = s.check_out_at ? new Date(s.check_out_at).toLocaleString() : 'β€”';
87
+ const duration = formatDuration(p.duration_minutes);
88
+ const fee = p.total_fee_egp != null ? p.total_fee_egp + ' EGP' : 'β€”';
89
+
90
+ // Payment status
91
+ const notes = (s.notes || '').toLowerCase();
92
+ let payBadge = '⏳ Pending';
93
+ if (s.payment_confirmed_at) payBadge = 'πŸ’³ Visa Paid';
94
+ else if (s.status === 'paid' || ps.is_paid) payBadge = 'βœ… Paid';
95
+ else if (notes.includes('intent_cash') || notes.includes('cash_confirmed')) payBadge = 'πŸ’΅ Cash';
96
+
97
+ // Status
98
+ const status = s.status || (s.check_out_at ? 'exited' : 'entered');
99
+ let stLabel, stClass;
100
+ if (status === 'exited' || s.check_out_at) { stLabel = 'Completed'; stClass = 'status-registered'; }
101
+ else if (status === 'paid') { stLabel = 'Paid (Inside)'; stClass = 'status-registered'; }
102
+ else { stLabel = 'Active'; stClass = 'status-active'; }
103
+
104
+ return `<tr>
105
+ <td class="mono">${plate}</td>
106
+ <td>${ownerName}</td>
107
+ <td>${entry}</td><td>${exit}</td>
108
+ <td>${duration}</td><td>${fee}</td><td>${payBadge}</td>
109
+ <td><span class="status ${stClass}">${stLabel}</span></td>
110
+ </tr>`;
111
+ }).join('');
112
+ } catch(e) { console.error(e); }
113
+ }
114
+
115
+ loadHistory();
116
+ </script>
117
+ {% endblock %}
templates/admin/users.html ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}User Management β€” Ain El Aql{% endblock %}
3
+
4
+ {% block body %}
5
+ <div class="app-layout">
6
+ {% with role='admin', active='users' %}
7
+ {% include "partials/sidebar.html" %}
8
+ {% endwith %}
9
+
10
+ <main class="main-content">
11
+ <div class="page-header fade-in">
12
+ <h1 class="page-title">User Management</h1>
13
+ <p class="page-subtitle">View and manage all registered users.</p>
14
+ </div>
15
+
16
+ <div class="card fade-in">
17
+ <div class="card-header">
18
+ <span class="card-title">πŸ‘₯ All Users</span>
19
+ <button class="btn btn-outline" onclick="loadUsers()">πŸ”„ Refresh</button>
20
+ </div>
21
+ <div class="table-wrapper">
22
+ <table>
23
+ <thead>
24
+ <tr>
25
+ <th>Name</th>
26
+ <th>Username</th>
27
+ <th>Role</th>
28
+ <th>Phone</th>
29
+ <th>Email</th>
30
+ </tr>
31
+ </thead>
32
+ <tbody id="usersBody">
33
+ <tr><td colspan="5" class="text-center" style="padding:2rem;color:var(--text-muted);">Loading users...</td></tr>
34
+ </tbody>
35
+ </table>
36
+ </div>
37
+ </div>
38
+ </main>
39
+ </div>
40
+ {% endblock %}
41
+
42
+ {% block scripts %}
43
+ <script>
44
+ async function loadUsers() {
45
+ const body = document.getElementById('usersBody');
46
+ try {
47
+ const res = await fetch('/admin/api/users');
48
+ const data = await res.json();
49
+ const users = Array.isArray(data) ? data : (data.users || data.data || []);
50
+
51
+ if (!users.length) {
52
+ body.innerHTML = '<tr><td colspan="5" class="text-center" style="padding:2rem;color:var(--text-muted);">No users found.</td></tr>';
53
+ return;
54
+ }
55
+
56
+ body.innerHTML = users.map(u => {
57
+ const name = u.full_name || (u.first_name + ' ' + (u.last_name || '')) || 'β€”';
58
+ const role = u.role || 'user';
59
+ const roleClass = role === 'admin' ? 'status-registered' : (role === 'security' ? 'status-active' : 'status-unregistered');
60
+ return `<tr>
61
+ <td style="font-weight:bold;color:white;">${name}</td>
62
+ <td class="mono">${u.username || 'β€”'}</td>
63
+ <td><span class="status ${roleClass}">${role.toUpperCase()}</span></td>
64
+ <td>${u.phone_number || 'β€”'}</td>
65
+ <td>${u.email || 'β€”'}</td>
66
+ </tr>`;
67
+ }).join('');
68
+ } catch (err) {
69
+ console.error(err);
70
+ body.innerHTML = '<tr><td colspan="5" class="text-center" style="padding:2rem;color:var(--error);">Failed to load users.</td></tr>';
71
+ }
72
+ }
73
+
74
+ loadUsers();
75
+ </script>
76
+ {% endblock %}
templates/admin/vehicles.html ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Vehicles β€” Ain El Aql{% endblock %}
3
+ {% block body %}
4
+ <div class="app-layout">
5
+ {% with role='admin', active='vehicles' %}
6
+ {% include "partials/sidebar.html" %}
7
+ {% endwith %}
8
+
9
+ <main class="main-content">
10
+ <div class="page-header fade-in flex-between">
11
+ <div>
12
+ <h1 class="page-title">Vehicle Management</h1>
13
+ <p class="page-subtitle">All vehicles currently in the system</p>
14
+ </div>
15
+ <button class="btn btn-outline" onclick="loadVehicles()"><span>πŸ”„</span> Refresh</button>
16
+ </div>
17
+
18
+ <div class="stats-grid fade-in">
19
+ <div class="stat-card blue">
20
+ <div class="stat-label">Total Vehicles</div>
21
+ <div class="stat-value blue" id="statTotal">β€”</div>
22
+ </div>
23
+ <div class="stat-card green">
24
+ <div class="stat-label">Currently Inside</div>
25
+ <div class="stat-value green" id="statActive">β€”</div>
26
+ </div>
27
+ <div class="stat-card purple">
28
+ <div class="stat-label">Registered</div>
29
+ <div class="stat-value purple" id="statRegistered">β€”</div>
30
+ </div>
31
+ <div class="stat-card red">
32
+ <div class="stat-label">Unregistered</div>
33
+ <div class="stat-value red" id="statUnregistered">β€”</div>
34
+ </div>
35
+ </div>
36
+
37
+ <div class="card fade-in">
38
+ <div class="table-wrapper" style="max-height:500px;overflow-y:auto;">
39
+ <table>
40
+ <thead>
41
+ <tr>
42
+ <th>Plate (AR)</th>
43
+ <th>Owner / User</th>
44
+ <th>Status</th>
45
+ <th>Duration</th>
46
+ <th>Fee</th>
47
+ <th>Payment</th>
48
+ </tr>
49
+ </thead>
50
+ <tbody id="vehBody">
51
+ <tr><td colspan="6" class="text-center" style="padding:2rem;color:var(--text-muted);">Loading...</td></tr></tbody>
52
+ </table>
53
+ </div>
54
+ </div>
55
+ </main>
56
+ </div>
57
+ {% endblock %}
58
+
59
+ {% block scripts %}
60
+ <script>
61
+ async function loadVehicles() {
62
+ try {
63
+ // Use inside-cars feed + history to show all known vehicles
64
+ const [insideR, histR] = await Promise.all([
65
+ fetch('/admin/api/inside-cars'),
66
+ fetch('/admin/api/history')
67
+ ]);
68
+
69
+ const insideData = await insideR.json();
70
+ const histData = await histR.json();
71
+
72
+ const insideItems = insideData.items || insideData.data || [];
73
+ const histItems = histData.history || histData.items || histData.data || [];
74
+
75
+ // Deduplicate by session id
76
+ const seen = new Set();
77
+ const allItems = [];
78
+
79
+ // Inside cars first (active)
80
+ for (const item of insideItems) {
81
+ const s = item.session || item;
82
+ const sid = s.id || '';
83
+ if (sid && !seen.has(sid)) { seen.add(sid); allItems.push({...item, _isInside: true}); }
84
+ }
85
+
86
+ // Then history (completed)
87
+ for (const item of histItems) {
88
+ const s = item.session || item;
89
+ const sid = s.id || '';
90
+ if (sid && !seen.has(sid)) { seen.add(sid); allItems.push({...item, _isInside: false}); }
91
+ }
92
+
93
+ // Update stats
94
+ // Update stats
95
+ const unregistered = allItems.filter(i => {
96
+ const v = i.vehicle || {};
97
+ return !v.id && !v.plate_letters_ar;
98
+ }).length;
99
+ const registered = allItems.length - unregistered;
100
+ const insideCount = allItems.filter(i => i._isInside).length;
101
+
102
+ document.getElementById('statTotal').textContent = allItems.length;
103
+ document.getElementById('statActive').textContent = insideCount;
104
+ document.getElementById('statRegistered').textContent = registered;
105
+ document.getElementById('statUnregistered').textContent = unregistered;
106
+
107
+ renderVehicles(allItems);
108
+ } catch(e) { console.error(e); }
109
+ }
110
+
111
+ function formatDuration(mins) {
112
+ if (mins == null) return 'β€”';
113
+ const h = Math.floor(mins / 60);
114
+ const m = Math.floor(mins % 60);
115
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
116
+ }
117
+
118
+ function renderVehicles(items) {
119
+ const body = document.getElementById('vehBody');
120
+ if (!items || !items.length) {
121
+ body.innerHTML = '<tr><td colspan="6" class="text-center" style="padding:2rem;color:var(--text-muted);">No vehicles found</td></tr>';
122
+ return;
123
+ }
124
+ body.innerHTML = items.map(item => {
125
+ const s = item.session || item;
126
+ const v = item.vehicle || {};
127
+ const p = item.pricing || {};
128
+
129
+ const plateAr = s.plate_arabic || ((v.plate_letters_ar||'') + ' ' + (v.plate_numbers_ar||'')).trim() || 'β€”';
130
+
131
+ // Attempt to extract username from backend's profile if available (depends on API return shape)
132
+ // Sometimes backend attaches `owner` to the vehicle object
133
+ let ownerName = 'β€”';
134
+ if (v.owner) {
135
+ ownerName = v.owner.username || v.owner.full_name || v.owner.first_name || 'β€”';
136
+ } else if (v.owner_id) {
137
+ ownerName = 'Registered';
138
+ }
139
+
140
+ const isGuest = !v.id && !v.plate_letters_ar;
141
+ if (isGuest) {
142
+ ownerName = '<span class="status status-unregistered" style="font-size:0.7rem;padding:2px 6px;">Unregistered</span>';
143
+ }
144
+
145
+ const isInside = item._isInside;
146
+ const status = isInside ? 'Inside' : 'Left';
147
+ const stClass = isInside ? 'status-active' : 'status-unregistered';
148
+
149
+ const duration = formatDuration(p.duration_minutes);
150
+ const fee = p.total_fee_egp != null ? p.total_fee_egp + ' EGP' : 'β€”';
151
+
152
+ // Payment badge
153
+ const notes = (s.notes || '').toLowerCase();
154
+ const hasCashIntent = notes.includes('intent_cash') || notes.includes('cash');
155
+ let isCash = isGuest || hasCashIntent;
156
+ let isPaidVisa = s.payment_confirmed_at != null && !isCash;
157
+
158
+ let payBadge = 'β€”';
159
+ if (isPaidVisa) payBadge = '<span style="color:var(--accent-blue);">πŸ’³ Visa</span>';
160
+ else if (isCash) payBadge = '<span style="color:var(--accent-green);">πŸ’΅ Cash</span>';
161
+ else if (isInside) payBadge = '⏳ Pending';
162
+
163
+ return `<tr>
164
+ <td class="mono">${plateAr}</td>
165
+ <td>${ownerName}</td>
166
+ <td><span class="status ${stClass}">${status}</span></td>
167
+ <td>${duration}</td><td>${fee}</td>
168
+ <td>${payBadge}</td>
169
+ </tr>`;
170
+ }).join('');
171
+ }
172
+
173
+ loadVehicles();
174
+ </script>
175
+ {% endblock %}
templates/auth/login.html ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Login β€” Ain El Aql{% endblock %}
3
+ {% block body %}
4
+ <div class="auth-wrapper">
5
+ <div class="auth-card fade-in">
6
+ <div class="auth-logo">
7
+ <div class="auth-logo-icon">
8
+ <img src="{{ url_for('static', filename='assets/cctv_logo.png') }}" alt="Logo" style="width:100%; height:100%; object-fit:contain;">
9
+ </div>
10
+ <h1>Ain El Aql</h1>
11
+ <p>EALPR Security Dashboard</p>
12
+ </div>
13
+
14
+ {% with messages = get_flashed_messages(with_categories=true) %}
15
+ {% if messages %}
16
+ {% for cat, msg in messages %}
17
+ <div class="alert alert-{{ cat }}">{{ msg }}</div>
18
+ {% endfor %}
19
+ {% endif %}
20
+ {% endwith %}
21
+
22
+ <form method="POST" action="{{ url_for('auth.login_page') }}" id="loginForm">
23
+ <div class="form-group">
24
+ <label class="form-label" for="identifier">Username / Email / Phone</label>
25
+ <input type="text" class="form-input" id="identifier" name="identifier"
26
+ placeholder="Enter your identifier" required autocomplete="username">
27
+ </div>
28
+ <div class="form-group">
29
+ <label class="form-label" for="password">Password</label>
30
+ <input type="password" class="form-input" id="password" name="password"
31
+ placeholder="Enter your password" required autocomplete="current-password">
32
+ </div>
33
+ <button type="submit" class="btn btn-primary w-full" id="loginBtn">
34
+ <span>πŸ”</span> Sign In
35
+ </button>
36
+ </form>
37
+
38
+ <div class="auth-footer">
39
+ Want to work with Ain El Aql? <a href="{{ url_for('auth.register_page') }}">Join Us</a>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ {% endblock %}
templates/auth/register.html ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Register β€” Ain El Aql{% endblock %}
3
+ {% block body %}
4
+ <div class="auth-wrapper">
5
+ <div class="auth-card fade-in">
6
+ <div class="auth-logo">
7
+ <div class="auth-logo-icon">
8
+ <img src="{{ url_for('static', filename='assets/cctv_logo.png') }}" alt="Logo" style="width:100%; height:100%; object-fit:contain;">
9
+ </div>
10
+ <h1>Create Account</h1>
11
+ <p>Join the Ain El Aql platform</p>
12
+ </div>
13
+
14
+ {% with messages = get_flashed_messages(with_categories=true) %}
15
+ {% if messages %}
16
+ {% for cat, msg in messages %}
17
+ <div class="alert alert-{{ cat }}">{{ msg }}</div>
18
+ {% endfor %}
19
+ {% endif %}
20
+ {% endwith %}
21
+
22
+ <form method="POST" action="{{ url_for('auth.register_page') }}">
23
+ <div class="grid-2">
24
+ <div class="form-group">
25
+ <label class="form-label" for="first_name">First Name</label>
26
+ <input type="text" class="form-input" id="first_name" name="first_name" placeholder="First name">
27
+ </div>
28
+ <div class="form-group">
29
+ <label class="form-label" for="last_name">Last Name</label>
30
+ <input type="text" class="form-input" id="last_name" name="last_name" placeholder="Last name">
31
+ </div>
32
+ </div>
33
+ <div class="form-group">
34
+ <label class="form-label" for="username">Username *</label>
35
+ <input type="text" class="form-input" id="username" name="username" placeholder="Choose a username" required>
36
+ </div>
37
+ <div class="form-group">
38
+ <label class="form-label" for="phone">Phone *</label>
39
+ <input type="tel" class="form-input" id="phone" name="phone" placeholder="+20 xxx xxx xxxx" required>
40
+ </div>
41
+ <div class="form-group">
42
+ <label class="form-label" for="reg_email">Email *</label>
43
+ <input type="email" class="form-input" id="reg_email" name="email" placeholder="email@example.com" required>
44
+ </div>
45
+ <div class="form-group">
46
+ <label class="form-label" for="reg_password">Password *</label>
47
+ <input type="password" class="form-input" id="reg_password" name="password" placeholder="Min 6 characters" required>
48
+ </div>
49
+ <div class="form-group">
50
+ <label class="form-label" for="nid">National ID (NID) *</label>
51
+ <input type="text" class="form-input" id="nid" name="nid" placeholder="14-digit National ID" required>
52
+ </div>
53
+ <div class="form-group">
54
+ <label class="form-label" for="role">Select Role *</label>
55
+ <select class="form-select" id="role" name="role" required>
56
+ <option value="security">Security Guard</option>
57
+ <option value="admin">Administrator</option>
58
+ </select>
59
+ </div>
60
+ <button type="submit" class="btn btn-primary w-full">
61
+ <span>✨</span> Create Account
62
+ </button>
63
+ </form>
64
+
65
+ <div class="auth-footer">
66
+ Already have an account? <a href="{{ url_for('auth.login_page') }}">Sign In</a>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ {% endblock %}
templates/base.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="description" content="Ain El Aql β€” EALPR Parking Security & Admin Dashboard">
7
+ <title>{% block title %}Ain El Aql{% endblock %}</title>
8
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
9
+ <link rel="icon" href="{{ url_for('static', filename='assets/cctv_logo.png') }}">
10
+ {% block head %}{% endblock %}
11
+ </head>
12
+ <body>
13
+ {% block body %}{% endblock %}
14
+ {% block scripts %}{% endblock %}
15
+ </body>
16
+ </html>
templates/partials/sidebar.html ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Sidebar partial β€” included in dashboard layouts -->
2
+ <aside class="sidebar">
3
+ <div class="sidebar-header">
4
+ <div class="sidebar-logo">
5
+ <img src="{{ url_for('static', filename='assets/cctv_logo.png') }}" alt="Logo" style="width:100%; height:100%; object-fit:contain;">
6
+ </div>
7
+ <div>
8
+ <div class="sidebar-title">Ain El Aql</div>
9
+ <div class="sidebar-subtitle">{{ role|upper }} Panel</div>
10
+ </div>
11
+ </div>
12
+
13
+ <nav class="sidebar-nav">
14
+ {% if role == 'security' %}
15
+ <a href="{{ url_for('security.dashboard') }}" class="nav-link {% if active == 'dashboard' %}active{% endif %}">
16
+ <span class="icon">πŸ“Š</span> Dashboard
17
+ </a>
18
+
19
+ {% elif role == 'admin' %}
20
+ <a href="{{ url_for('admin.dashboard') }}" class="nav-link {% if active == 'dashboard' %}active{% endif %}">
21
+ <span class="icon">πŸ“Š</span> Dashboard
22
+ </a>
23
+ <a href="{{ url_for('admin.garage') }}" class="nav-link {% if active == 'garage' %}active{% endif %}">
24
+ <span class="icon">πŸ—οΈ</span> Garage Status
25
+ </a>
26
+ <a href="{{ url_for('admin.users') }}" class="nav-link {% if active == 'users' %}active{% endif %}">
27
+ <span class="icon">πŸ‘₯</span> Users
28
+ </a>
29
+ <a href="{{ url_for('admin.vehicles') }}" class="nav-link {% if active == 'vehicles' %}active{% endif %}">
30
+ <span class="icon">πŸš—</span> Vehicles
31
+ </a>
32
+ <a href="{{ url_for('admin.history') }}" class="nav-link {% if active == 'history' %}active{% endif %}">
33
+ <span class="icon">πŸ“‹</span> History
34
+ </a>
35
+ {% endif %}
36
+ </nav>
37
+
38
+ <div class="sidebar-footer">
39
+ <div class="user-badge">
40
+ <div class="user-avatar">{{ (user.first_name or user.username or '?')[0]|upper }}</div>
41
+ <div>
42
+ <div class="user-name">{{ user.first_name or user.username or 'Staff' }}</div>
43
+ <div class="user-role">{{ role }}</div>
44
+ </div>
45
+ </div>
46
+ <a href="{{ url_for('auth.logout') }}" class="nav-link mt-2" style="color:var(--accent-red);">
47
+ <span class="icon">πŸšͺ</span> Sign Out
48
+ </a>
49
+ </div>
50
+ </aside>