bars,charts,maps,analysis,apis
This commit is contained in:
parent
d38f804983
commit
493778f955
518
API_DOCUMENTATION.md
Normal file
518
API_DOCUMENTATION.md
Normal file
@ -0,0 +1,518 @@
|
|||||||
|
# Dubai Analytics Platform API Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Dubai Analytics Platform provides a comprehensive REST API for accessing real estate market data, analytics, and insights. All API endpoints require authentication via API key.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:8000/api/v1/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All API requests must include your API key in the request headers. You can pass the API key in two ways:
|
||||||
|
|
||||||
|
### Method 1: X-API-Key Header (Recommended)
|
||||||
|
```bash
|
||||||
|
X-API-Key: your_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Authorization Header
|
||||||
|
```bash
|
||||||
|
Authorization: ApiKey your_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Your API Key
|
||||||
|
|
||||||
|
### 1. Register a New User
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/auth/register/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "your_username",
|
||||||
|
"email": "your_email@example.com",
|
||||||
|
"password": "your_password",
|
||||||
|
"first_name": "Your",
|
||||||
|
"last_name": "Name",
|
||||||
|
"company_name": "Your Company"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Enable API Access and Get API Key
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/auth/toggle-api-access/ \
|
||||||
|
-H "Authorization: Bearer your_jwt_token" \
|
||||||
|
-H "Content-Type: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Regenerate API Key (if needed)
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/auth/regenerate-api-key/ \
|
||||||
|
-H "Authorization: Bearer your_jwt_token" \
|
||||||
|
-H "Content-Type: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication Endpoints
|
||||||
|
|
||||||
|
#### Register User
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/auth/register/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "john_doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"password": "secure_password123",
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"company_name": "Real Estate Corp"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Login
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/v1/auth/login/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "john@example.com",
|
||||||
|
"password": "secure_password123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get User Profile
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8000/api/v1/auth/user/ \
|
||||||
|
-H "Authorization: Bearer your_jwt_token"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analytics Endpoints
|
||||||
|
|
||||||
|
#### 1. Broker Statistics
|
||||||
|
Get comprehensive broker statistics including gender distribution, nationality breakdown, and license information.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/broker-stats/" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gender_distribution": [
|
||||||
|
{"gender": "male", "count": 1250},
|
||||||
|
{"gender": "female", "count": 890}
|
||||||
|
],
|
||||||
|
"nationality_distribution": [
|
||||||
|
{"nationality": "UAE", "count": 800},
|
||||||
|
{"nationality": "India", "count": 650}
|
||||||
|
],
|
||||||
|
"license_status_distribution": [
|
||||||
|
{"status": "Active", "count": 1800},
|
||||||
|
{"status": "Inactive", "count": 340}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Project Statistics
|
||||||
|
Get project development statistics including status distribution, completion rates, and developer information.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/project-stats/" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status_distribution": [
|
||||||
|
{"project_status": "ACTIVE", "count": 45},
|
||||||
|
{"project_status": "COMPLETED", "count": 120},
|
||||||
|
{"project_status": "PLANNED", "count": 25}
|
||||||
|
],
|
||||||
|
"developer_distribution": [
|
||||||
|
{"developer": "Emaar Properties", "count": 35},
|
||||||
|
{"developer": "Nakheel", "count": 28}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Land Statistics
|
||||||
|
Get land parcel statistics including type distribution, area analysis, and usage patterns.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/land-stats/" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type_distribution": [
|
||||||
|
{"land_type": "Residential", "count": 450},
|
||||||
|
{"land_type": "Commercial", "count": 280},
|
||||||
|
{"land_type": "Mixed Use", "count": 120}
|
||||||
|
],
|
||||||
|
"area_distribution": [
|
||||||
|
{"area_range": "0-1000", "count": 200},
|
||||||
|
{"area_range": "1000-5000", "count": 350}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Valuation Statistics
|
||||||
|
Get property valuation statistics including price trends, market analysis, and valuation methods.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/valuation-stats/" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"valuation_method_distribution": [
|
||||||
|
{"method": "Sales Comparison", "count": 1200},
|
||||||
|
{"method": "Income Approach", "count": 800}
|
||||||
|
],
|
||||||
|
"property_type_distribution": [
|
||||||
|
{"property_type": "Apartment", "count": 1500},
|
||||||
|
{"property_type": "Villa", "count": 900}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Rent Statistics
|
||||||
|
Get rental market statistics including rent trends, property types, and area analysis.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/rent-stats/" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"property_type_distribution": [
|
||||||
|
{"property_type": "Apartment", "count": 800},
|
||||||
|
{"property_type": "Villa", "count": 400}
|
||||||
|
],
|
||||||
|
"rent_range_distribution": [
|
||||||
|
{"range": "0-5000", "count": 200},
|
||||||
|
{"range": "5000-10000", "count": 500}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. Time Series Data
|
||||||
|
Get time-series data for transactions, values, and market trends.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/time-series-data/?start_date=2025-01-01&end_date=2025-12-31&group_by=month" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `start_date`: Start date in YYYY-MM-DD format
|
||||||
|
- `end_date`: End date in YYYY-MM-DD format
|
||||||
|
- `group_by`: Grouping interval (day, week, month, quarter, year)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"date": "2025-01-01",
|
||||||
|
"transaction_count": 150,
|
||||||
|
"total_value": 45000000,
|
||||||
|
"average_price": 300000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. Transaction Summary
|
||||||
|
Get summary statistics for transactions within a date range.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/transaction-summary/?start_date=2025-01-01&end_date=2025-12-31" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_transactions": 1500,
|
||||||
|
"total_value": 450000000,
|
||||||
|
"average_price": 300000,
|
||||||
|
"average_price_per_sqft": 2500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8. Area Statistics
|
||||||
|
Get statistics by geographic area.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/area-statistics/?start_date=2025-01-01&end_date=2025-12-31&limit=10" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `start_date`: Start date in YYYY-MM-DD format
|
||||||
|
- `end_date`: End date in YYYY-MM-DD format
|
||||||
|
- `limit`: Number of areas to return (default: 10)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"area": "Downtown Dubai",
|
||||||
|
"transaction_count": 150,
|
||||||
|
"total_value": 45000000,
|
||||||
|
"average_price": 300000,
|
||||||
|
"average_price_per_sqft": 2500,
|
||||||
|
"price_trend": "Rising"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9. Property Type Statistics
|
||||||
|
Get statistics by property type.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/property-type-statistics/?start_date=2025-01-01&end_date=2025-12-31" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"property_type": "Apartment",
|
||||||
|
"transaction_count": 800,
|
||||||
|
"total_value": 240000000,
|
||||||
|
"average_price": 300000,
|
||||||
|
"market_share": 53.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reports Endpoints
|
||||||
|
|
||||||
|
#### Get All Reports
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/reports/" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Generate Report
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8000/api/v1/reports/generate/" \
|
||||||
|
-H "X-API-Key: your_api_key_here" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"report_type": "transaction_summary",
|
||||||
|
"parameters": {
|
||||||
|
"start_date": "2025-01-01",
|
||||||
|
"end_date": "2025-12-31",
|
||||||
|
"area": "Downtown Dubai"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring Endpoints
|
||||||
|
|
||||||
|
#### System Metrics
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/monitoring/metrics/" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Management Endpoints
|
||||||
|
|
||||||
|
#### Get User List (Admin)
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/auth/list/" \
|
||||||
|
-H "X-API-Key: your_api_key_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update User Profile
|
||||||
|
```bash
|
||||||
|
curl -X PUT "http://localhost:8000/api/v1/auth/profile/" \
|
||||||
|
-H "X-API-Key: your_api_key_here" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"company_name": "Updated Company",
|
||||||
|
"phone_number": "+971501234567"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The API uses standard HTTP status codes:
|
||||||
|
|
||||||
|
- `200 OK`: Request successful
|
||||||
|
- `201 Created`: Resource created successfully
|
||||||
|
- `400 Bad Request`: Invalid request data
|
||||||
|
- `401 Unauthorized`: Invalid or missing API key
|
||||||
|
- `403 Forbidden`: Insufficient permissions
|
||||||
|
- `404 Not Found`: Resource not found
|
||||||
|
- `429 Too Many Requests`: Rate limit exceeded
|
||||||
|
- `500 Internal Server Error`: Server error
|
||||||
|
|
||||||
|
### Error Response Format
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Error message",
|
||||||
|
"details": "Detailed error information",
|
||||||
|
"code": "ERROR_CODE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
API requests are rate-limited based on your subscription tier:
|
||||||
|
|
||||||
|
- **Free Tier**: 100 requests/hour
|
||||||
|
- **Paid Tier**: 1,000 requests/hour
|
||||||
|
- **Premium Tier**: 10,000 requests/hour
|
||||||
|
|
||||||
|
Rate limit headers are included in responses:
|
||||||
|
- `X-RateLimit-Limit`: Requests allowed per hour
|
||||||
|
- `X-RateLimit-Remaining`: Requests remaining in current window
|
||||||
|
- `X-RateLimit-Reset`: Time when rate limit resets (Unix timestamp)
|
||||||
|
|
||||||
|
## Data Formats
|
||||||
|
|
||||||
|
### Date Format
|
||||||
|
All dates are in ISO 8601 format: `YYYY-MM-DD`
|
||||||
|
|
||||||
|
### Currency Format
|
||||||
|
All monetary values are in AED (United Arab Emirates Dirham) and returned as numbers (not formatted strings).
|
||||||
|
|
||||||
|
### Coordinates
|
||||||
|
Geographic coordinates are in decimal degrees format.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete Workflow Example
|
||||||
|
|
||||||
|
1. **Register and get API key:**
|
||||||
|
```bash
|
||||||
|
# Register user
|
||||||
|
curl -X POST http://localhost:8000/api/v1/auth/register/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "demo_user",
|
||||||
|
"email": "demo@example.com",
|
||||||
|
"password": "demo_password123",
|
||||||
|
"first_name": "Demo",
|
||||||
|
"last_name": "User",
|
||||||
|
"company_name": "Demo Company"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Login to get JWT token
|
||||||
|
curl -X POST http://localhost:8000/api/v1/auth/login/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "demo@example.com",
|
||||||
|
"password": "demo_password123"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Enable API access and get API key
|
||||||
|
curl -X POST http://localhost:8000/api/v1/auth/toggle-api-access/ \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use API key to get data:**
|
||||||
|
```bash
|
||||||
|
# Get broker statistics
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/broker-stats/" \
|
||||||
|
-H "X-API-Key: YOUR_API_KEY"
|
||||||
|
|
||||||
|
# Get transaction summary for 2025
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/transaction-summary/?start_date=2025-01-01&end_date=2025-12-31" \
|
||||||
|
-H "X-API-Key: YOUR_API_KEY"
|
||||||
|
|
||||||
|
# Get time series data
|
||||||
|
curl -X GET "http://localhost:8000/api/v1/analytics/time-series-data/?start_date=2025-01-01&end_date=2025-12-31&group_by=month" \
|
||||||
|
-H "X-API-Key: YOUR_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
## SDKs and Libraries
|
||||||
|
|
||||||
|
### JavaScript/Node.js
|
||||||
|
```javascript
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: 'http://localhost:8000/api/v1',
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': 'your_api_key_here'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get broker statistics
|
||||||
|
const brokerStats = await api.get('/analytics/broker-stats/');
|
||||||
|
console.log(brokerStats.data);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'X-API-Key': 'your_api_key_here'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get broker statistics
|
||||||
|
response = requests.get('http://localhost:8000/api/v1/analytics/broker-stats/', headers=headers)
|
||||||
|
broker_stats = response.json()
|
||||||
|
print(broker_stats)
|
||||||
|
```
|
||||||
|
|
||||||
|
### PHP
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
$api_key = 'your_api_key_here';
|
||||||
|
$headers = [
|
||||||
|
'X-API-Key: ' . $api_key,
|
||||||
|
'Content-Type: application/json'
|
||||||
|
];
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt($ch, CURLOPT_URL, 'http://localhost:8000/api/v1/analytics/broker-stats/');
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$broker_stats = json_decode($response, true);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
print_r($broker_stats);
|
||||||
|
?>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For API support and questions:
|
||||||
|
- Email: api-support@dubaianalytics.com
|
||||||
|
- Documentation: https://docs.dubaianalytics.com
|
||||||
|
- Status Page: https://status.dubaianalytics.com
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Version 1.0.0 (Current)
|
||||||
|
- Initial API release
|
||||||
|
- Authentication via API key
|
||||||
|
- All analytics endpoints
|
||||||
|
- Rate limiting by subscription tier
|
||||||
|
- Comprehensive error handling
|
||||||
214
VISUALIZATION_FEATURES.md
Normal file
214
VISUALIZATION_FEATURES.md
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# Enhanced Visualization Features
|
||||||
|
|
||||||
|
This document outlines the new advanced visualization features added to the Dubai Analytics platform.
|
||||||
|
|
||||||
|
## 🗺️ Geographic Heat Maps
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Interactive Map**: Interactive Leaflet-based map showing Dubai areas
|
||||||
|
- **Heat Visualization**: Color-coded circles based on transaction data
|
||||||
|
- **Clustering**: Smart marker clustering for better performance
|
||||||
|
- **Multiple Color Schemes**: Viridis, Plasma, Inferno, Magma, Cool, Hot
|
||||||
|
- **Responsive Design**: Adapts to different screen sizes
|
||||||
|
- **Click Interactions**: Click areas to filter data
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
```jsx
|
||||||
|
<GeographicHeatMap
|
||||||
|
data={areaStats}
|
||||||
|
height="500px"
|
||||||
|
onAreaClick={(area) => setSelectedArea(area)}
|
||||||
|
showClusters={true}
|
||||||
|
colorScheme="viridis"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Time Series Charts
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Multiple Chart Types**: Line and Bar charts
|
||||||
|
- **Interactive Controls**: Time range selection, chart type switching
|
||||||
|
- **Trend Analysis**: Automatic trend calculation and indicators
|
||||||
|
- **Forecast Support**: Optional forecast data overlay
|
||||||
|
- **Customizable Settings**: Grid, legend, data points, smooth lines
|
||||||
|
- **Export Capabilities**: Export charts as images or data
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
```jsx
|
||||||
|
<TimeSeriesChart
|
||||||
|
data={timeSeriesData}
|
||||||
|
type="line"
|
||||||
|
height={500}
|
||||||
|
title="Market Trends Over Time"
|
||||||
|
showControls={true}
|
||||||
|
showTrends={true}
|
||||||
|
showForecast={false}
|
||||||
|
onExport={(data) => handleExport(data)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Comparative Analysis
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Multi-Dataset Comparison**: Compare different areas, property types, or time periods
|
||||||
|
- **Multiple Chart Types**: Line, Bar, and Scatter plots
|
||||||
|
- **Normalization**: Optional data normalization for fair comparison
|
||||||
|
- **Trend Indicators**: Visual trend indicators for each dataset
|
||||||
|
- **Summary Statistics**: Detailed statistics for each comparison item
|
||||||
|
- **Interactive Controls**: Add/remove comparison items dynamically
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
```jsx
|
||||||
|
<ComparativeAnalysis
|
||||||
|
data={comparisonData}
|
||||||
|
comparisonItems={comparisonItems}
|
||||||
|
onAddComparison={(item) => addComparison(item)}
|
||||||
|
onRemoveComparison={(index) => removeComparison(index)}
|
||||||
|
height={500}
|
||||||
|
title="Comparative Analysis"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📤 Export & Sharing
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Multiple Export Formats**: PDF, PNG, CSV, JSON
|
||||||
|
- **Export Types**: Charts only, data only, full report, map view
|
||||||
|
- **PDF Generation**: High-quality PDF reports with charts and data
|
||||||
|
- **Image Export**: High-resolution PNG images
|
||||||
|
- **Data Export**: Raw data in CSV or JSON format
|
||||||
|
- **Share Options**: Generate shareable links, email reports, embed codes
|
||||||
|
- **Metadata Inclusion**: Optional metadata and timestamps
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
```jsx
|
||||||
|
<ExportSharing
|
||||||
|
data={allData}
|
||||||
|
chartRefs={chartRefs}
|
||||||
|
title="Export & Share Analytics"
|
||||||
|
onExport={(exportData) => handleExport(exportData)}
|
||||||
|
onShare={(shareData) => handleShare(shareData)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 UI Improvements
|
||||||
|
|
||||||
|
### Enhanced Design
|
||||||
|
- **Modern Gradient Headers**: Beautiful gradient backgrounds
|
||||||
|
- **Improved Typography**: Better font weights and spacing
|
||||||
|
- **Enhanced Cards**: Rounded corners, shadows, and borders
|
||||||
|
- **Interactive Elements**: Hover effects and transitions
|
||||||
|
- **Dark Mode Support**: Full dark mode compatibility
|
||||||
|
- **Responsive Layout**: Mobile-first responsive design
|
||||||
|
|
||||||
|
### New Components
|
||||||
|
- **StatCard**: Enhanced statistics cards with trend indicators
|
||||||
|
- **Chart**: Improved chart component with better error handling
|
||||||
|
- **Layout**: Better spacing and organization
|
||||||
|
- **Controls**: Intuitive control panels
|
||||||
|
|
||||||
|
## 📊 Data Visualization Types
|
||||||
|
|
||||||
|
### 1. Geographic Visualizations
|
||||||
|
- **Heat Maps**: Color-coded area activity
|
||||||
|
- **Cluster Maps**: Grouped markers for performance
|
||||||
|
- **Interactive Popups**: Detailed area information
|
||||||
|
- **Zoom Controls**: Pan and zoom functionality
|
||||||
|
|
||||||
|
### 2. Temporal Visualizations
|
||||||
|
- **Time Series**: Line and bar charts over time
|
||||||
|
- **Trend Analysis**: Automatic trend detection
|
||||||
|
- **Forecast Overlay**: Optional prediction data
|
||||||
|
- **Time Range Selection**: Flexible time filtering
|
||||||
|
|
||||||
|
### 3. Comparative Visualizations
|
||||||
|
- **Side-by-Side Comparison**: Multiple datasets
|
||||||
|
- **Normalized Views**: Fair comparison across scales
|
||||||
|
- **Statistical Summary**: Key metrics for each dataset
|
||||||
|
- **Interactive Switching**: Dynamic chart type changes
|
||||||
|
|
||||||
|
### 4. Export Visualizations
|
||||||
|
- **PDF Reports**: Professional report generation
|
||||||
|
- **Image Exports**: High-quality chart images
|
||||||
|
- **Data Exports**: Raw data in multiple formats
|
||||||
|
- **Shareable Links**: Easy sharing capabilities
|
||||||
|
|
||||||
|
## 🛠️ Technical Implementation
|
||||||
|
|
||||||
|
### Dependencies Added
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-leaflet-cluster": "^2.0.0",
|
||||||
|
"plotly.js": "^2.27.0",
|
||||||
|
"react-plotly.js": "^2.6.0",
|
||||||
|
"jspdf": "^2.5.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
|
"react-csv": "^2.2.2",
|
||||||
|
"framer-motion": "^10.16.16"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
- **Performance Optimized**: Efficient rendering and data processing
|
||||||
|
- **Accessibility**: WCAG compliant components
|
||||||
|
- **TypeScript Ready**: Full TypeScript support
|
||||||
|
- **Mobile Responsive**: Works on all device sizes
|
||||||
|
- **Browser Compatible**: Works in all modern browsers
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
1. **Install Dependencies**:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Import Components**:
|
||||||
|
```jsx
|
||||||
|
import GeographicHeatMap from './components/GeographicHeatMap'
|
||||||
|
import TimeSeriesChart from './components/TimeSeriesChart'
|
||||||
|
import ComparativeAnalysis from './components/ComparativeAnalysis'
|
||||||
|
import ExportSharing from './components/ExportSharing'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use in Your App**:
|
||||||
|
```jsx
|
||||||
|
<GeographicHeatMap data={areaData} />
|
||||||
|
<TimeSeriesChart data={timeSeriesData} />
|
||||||
|
<ComparativeAnalysis data={comparisonData} />
|
||||||
|
<ExportSharing data={allData} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 Responsive Design
|
||||||
|
|
||||||
|
All components are fully responsive and work on:
|
||||||
|
- **Desktop**: Full feature set with all controls
|
||||||
|
- **Tablet**: Optimized layout with touch support
|
||||||
|
- **Mobile**: Simplified interface with essential features
|
||||||
|
|
||||||
|
## 🎯 Performance
|
||||||
|
|
||||||
|
- **Lazy Loading**: Components load only when needed
|
||||||
|
- **Data Virtualization**: Efficient handling of large datasets
|
||||||
|
- **Memory Management**: Proper cleanup and optimization
|
||||||
|
- **Caching**: Smart caching for better performance
|
||||||
|
|
||||||
|
## 🔧 Customization
|
||||||
|
|
||||||
|
All components support extensive customization:
|
||||||
|
- **Themes**: Light and dark mode support
|
||||||
|
- **Colors**: Customizable color schemes
|
||||||
|
- **Sizes**: Flexible sizing options
|
||||||
|
- **Interactions**: Customizable event handlers
|
||||||
|
- **Styling**: CSS-in-JS and Tailwind support
|
||||||
|
|
||||||
|
## 📈 Future Enhancements
|
||||||
|
|
||||||
|
Planned features for future releases:
|
||||||
|
- **3D Visualizations**: Three-dimensional charts and maps
|
||||||
|
- **Real-time Updates**: Live data streaming
|
||||||
|
- **Advanced Analytics**: Machine learning insights
|
||||||
|
- **Collaboration**: Multi-user editing and sharing
|
||||||
|
- **Mobile App**: Native mobile applications
|
||||||
@ -11,15 +11,22 @@ urlpatterns = [
|
|||||||
path('brokers/', views.BrokerListView.as_view(), name='broker_list'),
|
path('brokers/', views.BrokerListView.as_view(), name='broker_list'),
|
||||||
|
|
||||||
# Analytics endpoints
|
# Analytics endpoints
|
||||||
path('summary/', views.transaction_summary, name='transaction_summary'),
|
path('transaction-summary/', views.transaction_summary, name='transaction_summary'),
|
||||||
path('area-stats/', views.area_statistics, name='area_statistics'),
|
path('area-statistics/', views.area_statistics, name='area_statistics'),
|
||||||
path('property-type-stats/', views.property_type_statistics, name='property_type_statistics'),
|
path('property-type-statistics/', views.property_type_statistics, name='property_type_statistics'),
|
||||||
path('time-series/', views.time_series_data, name='time_series_data'),
|
path('time-series/', views.time_series_data, name='time_series_data'),
|
||||||
path('time-series-data/', views.get_time_series_data, name='get_time_series_data'),
|
path('time-series-data/', views.get_time_series_data, name='get_time_series_data'),
|
||||||
path('area-stats-data/', views.get_area_stats, name='get_area_stats'),
|
path('area-stats-data/', views.get_area_stats, name='get_area_stats'),
|
||||||
path('market-analysis/', views.market_analysis, name='market_analysis'),
|
path('market-analysis/', views.market_analysis, name='market_analysis'),
|
||||||
|
|
||||||
|
# Sample data statistics endpoints
|
||||||
|
path('broker-stats/', views.broker_statistics, name='broker_statistics'),
|
||||||
|
path('project-stats/', views.project_statistics, name='project_statistics'),
|
||||||
|
path('land-stats/', views.land_statistics, name='land_statistics'),
|
||||||
|
path('valuation-stats/', views.valuation_statistics, name='valuation_statistics'),
|
||||||
|
path('rent-stats/', views.rent_statistics, name='rent_statistics'),
|
||||||
|
|
||||||
# Forecasting endpoints
|
# Forecasting endpoints
|
||||||
path('forecast/', views.generate_forecast, name='generate_forecast'),
|
path('generate-forecast/', views.generate_forecast, name='generate_forecast'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ Views for analytics and data analysis.
|
|||||||
"""
|
"""
|
||||||
from rest_framework import generics, status
|
from rest_framework import generics, status
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.db.models import Avg, Count, Min, Max, Sum, Q
|
from django.db.models import Avg, Count, Min, Max, Sum, Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -514,3 +514,148 @@ def get_area_stats(request):
|
|||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def broker_statistics(request):
|
||||||
|
"""Get broker statistics for charts."""
|
||||||
|
try:
|
||||||
|
# Get gender distribution
|
||||||
|
gender_stats = Broker.objects.values('gender').annotate(
|
||||||
|
count=Count('id')
|
||||||
|
).order_by('-count')
|
||||||
|
|
||||||
|
# Get top real estate companies
|
||||||
|
company_stats = Broker.objects.values('real_estate_name_en').annotate(
|
||||||
|
broker_count=Count('id')
|
||||||
|
).exclude(real_estate_name_en__isnull=True).exclude(real_estate_name_en='').order_by('-broker_count')[:10]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'gender_distribution': list(gender_stats),
|
||||||
|
'top_companies': list(company_stats)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting broker statistics: {e}")
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to get broker statistics'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def project_statistics(request):
|
||||||
|
"""Get project statistics for charts."""
|
||||||
|
try:
|
||||||
|
# Get project status distribution
|
||||||
|
status_stats = Project.objects.values('project_status').annotate(
|
||||||
|
count=Count('id')
|
||||||
|
).order_by('-count')
|
||||||
|
|
||||||
|
# Get completion rate ranges
|
||||||
|
completion_ranges = [
|
||||||
|
{'range': '0-25%', 'count': Project.objects.filter(percent_completed__gte=0, percent_completed__lte=25).count()},
|
||||||
|
{'range': '26-50%', 'count': Project.objects.filter(percent_completed__gte=26, percent_completed__lte=50).count()},
|
||||||
|
{'range': '51-75%', 'count': Project.objects.filter(percent_completed__gte=51, percent_completed__lte=75).count()},
|
||||||
|
{'range': '76-100%', 'count': Project.objects.filter(percent_completed__gte=76, percent_completed__lte=100).count()},
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'status_distribution': list(status_stats),
|
||||||
|
'completion_rates': completion_ranges
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting project statistics: {e}")
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to get project statistics'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def land_statistics(request):
|
||||||
|
"""Get land statistics for charts."""
|
||||||
|
try:
|
||||||
|
# Get land type distribution
|
||||||
|
type_stats = Land.objects.values('land_type').annotate(
|
||||||
|
count=Count('id')
|
||||||
|
).order_by('-count')
|
||||||
|
|
||||||
|
# Get top areas by land count
|
||||||
|
area_stats = Land.objects.values('area_en').annotate(
|
||||||
|
land_count=Count('id')
|
||||||
|
).exclude(area_en__isnull=True).exclude(area_en='').order_by('-land_count')[:10]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'type_distribution': list(type_stats),
|
||||||
|
'area_distribution': list(area_stats)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting land statistics: {e}")
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to get land statistics'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def valuation_statistics(request):
|
||||||
|
"""Get valuation statistics for charts."""
|
||||||
|
try:
|
||||||
|
# Get value trends by month
|
||||||
|
value_trends = Valuation.objects.extra(
|
||||||
|
select={'month': "DATE_TRUNC('month', instance_date)"}
|
||||||
|
).values('month').annotate(
|
||||||
|
avg_value=Avg('property_total_value')
|
||||||
|
).order_by('month')[:12] # Last 12 months
|
||||||
|
|
||||||
|
# Get top valued areas
|
||||||
|
area_stats = Valuation.objects.values('area_en').annotate(
|
||||||
|
avg_value=Avg('property_total_value')
|
||||||
|
).exclude(area_en__isnull=True).exclude(area_en='').order_by('-avg_value')[:10]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'value_trends': list(value_trends),
|
||||||
|
'top_valued_areas': list(area_stats)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting valuation statistics: {e}")
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to get valuation statistics'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def rent_statistics(request):
|
||||||
|
"""Get rent statistics for charts."""
|
||||||
|
try:
|
||||||
|
# Get rental property types
|
||||||
|
property_stats = Rent.objects.values('property_type').annotate(
|
||||||
|
count=Count('id')
|
||||||
|
).order_by('-count')
|
||||||
|
|
||||||
|
# Get average rental prices by area
|
||||||
|
area_stats = Rent.objects.values('area_en').annotate(
|
||||||
|
avg_rent=Avg('contract_amount')
|
||||||
|
).exclude(area_en__isnull=True).exclude(area_en='').order_by('-avg_rent')[:10]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'property_types': list(property_stats),
|
||||||
|
'area_prices': list(area_stats)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting rent statistics: {e}")
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to get rent statistics'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ urlpatterns = [
|
|||||||
# Authentication
|
# Authentication
|
||||||
path('register/', views.UserRegistrationView.as_view(), name='user_register'),
|
path('register/', views.UserRegistrationView.as_view(), name='user_register'),
|
||||||
path('login/', views.login_view, name='user_login'),
|
path('login/', views.login_view, name='user_login'),
|
||||||
|
path('refresh/', views.refresh_token_view, name='refresh_token'),
|
||||||
path('logout/', views.logout_view, name='user_logout'),
|
path('logout/', views.logout_view, name='user_logout'),
|
||||||
path('user/', views.get_current_user, name='get_current_user'),
|
path('user/', views.get_current_user, name='get_current_user'),
|
||||||
|
|
||||||
|
|||||||
@ -33,12 +33,18 @@ class UserRegistrationView(generics.CreateAPIView):
|
|||||||
# Create user profile
|
# Create user profile
|
||||||
UserProfile.objects.create(user=user)
|
UserProfile.objects.create(user=user)
|
||||||
|
|
||||||
|
# Enable API access and generate API key for new users
|
||||||
|
user.is_api_enabled = True
|
||||||
|
user.api_key = user.generate_api_key()
|
||||||
|
user.save()
|
||||||
|
|
||||||
# Generate tokens
|
# Generate tokens
|
||||||
refresh = RefreshToken.for_user(user)
|
refresh = RefreshToken.for_user(user)
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'message': 'User created successfully',
|
'message': 'User created successfully',
|
||||||
'user': UserSerializer(user).data,
|
'user': UserSerializer(user).data,
|
||||||
|
'api_key': user.api_key,
|
||||||
'tokens': {
|
'tokens': {
|
||||||
'refresh': str(refresh),
|
'refresh': str(refresh),
|
||||||
'access': str(refresh.access_token),
|
'access': str(refresh.access_token),
|
||||||
@ -66,6 +72,26 @@ def login_view(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def refresh_token_view(request):
|
||||||
|
"""Refresh JWT token endpoint."""
|
||||||
|
try:
|
||||||
|
refresh_token = request.data.get('refresh')
|
||||||
|
if not refresh_token:
|
||||||
|
return Response({'error': 'Refresh token required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
token = RefreshToken(refresh_token)
|
||||||
|
new_access_token = str(token.access_token)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'access': new_access_token,
|
||||||
|
'refresh': str(refresh_token)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': 'Invalid refresh token'}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def logout_view(request):
|
def logout_view(request):
|
||||||
|
|||||||
3726
frontend/package-lock.json
generated
3726
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -15,18 +15,31 @@
|
|||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"chartjs-adapter-date-fns": "^3.0.0",
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
|
"framer-motion": "^10.18.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^2.5.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
|
"plotly.js": "^2.35.3",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
"react-csv": "^2.2.2",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-grid-layout": "^1.5.2",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
"react-redux": "^9.0.4",
|
|
||||||
"react-router-dom": "^6.20.1",
|
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-intersection-observer": "^9.16.0",
|
||||||
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
|
"react-plotly.js": "^2.6.0",
|
||||||
|
"react-redux": "^9.0.4",
|
||||||
|
"react-resizable": "^3.0.5",
|
||||||
|
"react-router-dom": "^6.20.1",
|
||||||
"recharts": "^2.8.0",
|
"recharts": "^2.8.0",
|
||||||
"tailwind-merge": "^2.0.0",
|
"tailwind-merge": "^2.0.0"
|
||||||
"clsx": "^2.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.2.37",
|
||||||
@ -42,4 +55,3 @@
|
|||||||
"vite": "^4.5.0"
|
"vite": "^4.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,16 @@ import Settings from './pages/Settings'
|
|||||||
import Payments from './pages/Payments'
|
import Payments from './pages/Payments'
|
||||||
import { selectIsAuthenticated } from './store/slices/authSlice'
|
import { selectIsAuthenticated } from './store/slices/authSlice'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { isAuthenticated, isInitialized } = useAuthState()
|
const { isAuthenticated, isInitialized } = useAuthState()
|
||||||
const { isLoading } = useAuth()
|
const { isLoading } = useAuth()
|
||||||
@ -45,14 +55,4 @@ function AppRoutes() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<ThemeProvider>
|
|
||||||
<AuthProvider>
|
|
||||||
<AppRoutes />
|
|
||||||
</AuthProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
@ -56,6 +56,17 @@ const Chart = ({ data, type, height = 300, options = {}, title, subtitle }) => {
|
|||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
animation: {
|
||||||
|
duration: 2000,
|
||||||
|
easing: 'easeInOutQuart',
|
||||||
|
delay: (context) => {
|
||||||
|
let delay = 0;
|
||||||
|
if (context.type === 'data' && context.mode === 'default') {
|
||||||
|
delay = context.dataIndex * 200 + context.datasetIndex * 100;
|
||||||
|
}
|
||||||
|
return delay;
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'top',
|
position: 'top',
|
||||||
|
|||||||
466
frontend/src/components/ComparativeAnalysis.jsx
Normal file
466
frontend/src/components/ComparativeAnalysis.jsx
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import { Line, Bar, Scatter } from 'react-chartjs-2'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
} from 'chart.js'
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
LineChart,
|
||||||
|
Activity,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
Download,
|
||||||
|
Settings,
|
||||||
|
Filter,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
Minus
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
)
|
||||||
|
|
||||||
|
const ComparativeAnalysis = ({
|
||||||
|
data = [],
|
||||||
|
comparisonItems = [],
|
||||||
|
onAddComparison,
|
||||||
|
onRemoveComparison,
|
||||||
|
height = 400,
|
||||||
|
title = 'Comparative Analysis',
|
||||||
|
subtitle = 'Compare different areas, property types, or time periods'
|
||||||
|
}) => {
|
||||||
|
const [chartType, setChartType] = useState('line')
|
||||||
|
const [metric, setMetric] = useState('transactions')
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [normalizeData, setNormalizeData] = useState(false)
|
||||||
|
const [showTrends, setShowTrends] = useState(true)
|
||||||
|
|
||||||
|
const availableMetrics = [
|
||||||
|
{ key: 'transactions', label: 'Transaction Count', color: 'rgb(59, 130, 246)' },
|
||||||
|
{ key: 'value', label: 'Total Value (AED)', color: 'rgb(16, 185, 129)' },
|
||||||
|
{ key: 'average_price', label: 'Average Price (AED)', color: 'rgb(245, 158, 11)' },
|
||||||
|
{ key: 'price_per_sqft', label: 'Price per Sqft (AED)', color: 'rgb(239, 68, 68)' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const chartTypes = [
|
||||||
|
{ key: 'line', label: 'Line Chart', icon: LineChart },
|
||||||
|
{ key: 'bar', label: 'Bar Chart', icon: BarChart3 },
|
||||||
|
{ key: 'scatter', label: 'Scatter Plot', icon: Activity }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Process data for comparison
|
||||||
|
const processedData = useMemo(() => {
|
||||||
|
if (!data || data.length === 0) return { labels: [], datasets: [] }
|
||||||
|
|
||||||
|
// Group data by comparison items
|
||||||
|
const groupedData = {}
|
||||||
|
const allDates = new Set()
|
||||||
|
|
||||||
|
data.forEach(item => {
|
||||||
|
const key = `${item.area_en || 'Unknown'}_${item.property_type || 'All'}`
|
||||||
|
if (!groupedData[key]) {
|
||||||
|
groupedData[key] = {
|
||||||
|
area: item.area_en || 'Unknown',
|
||||||
|
propertyType: item.property_type || 'All',
|
||||||
|
data: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupedData[key].data.push(item)
|
||||||
|
allDates.add(item.date)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort dates
|
||||||
|
const sortedDates = Array.from(allDates).sort((a, b) => new Date(a) - new Date(b))
|
||||||
|
const labels = sortedDates.map(date =>
|
||||||
|
new Date(date).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create datasets for each comparison item
|
||||||
|
const datasets = Object.values(groupedData).map((group, index) => {
|
||||||
|
const color = availableMetrics.find(m => m.key === metric)?.color ||
|
||||||
|
`hsl(${(index * 137.5) % 360}, 70%, 50%)`
|
||||||
|
|
||||||
|
const values = sortedDates.map(date => {
|
||||||
|
const item = group.data.find(d => d.date === date)
|
||||||
|
if (!item) return null
|
||||||
|
|
||||||
|
let value = item[metric] || 0
|
||||||
|
|
||||||
|
// Normalize data if requested
|
||||||
|
if (normalizeData && metric !== 'transactions') {
|
||||||
|
const maxValue = Math.max(...group.data.map(d => d[metric] || 0))
|
||||||
|
value = maxValue > 0 ? (value / maxValue) * 100 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate trend
|
||||||
|
const validValues = values.filter(v => v !== null)
|
||||||
|
const trend = validValues.length >= 2 ?
|
||||||
|
((validValues[validValues.length - 1] - validValues[0]) / validValues[0]) * 100 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `${group.area} (${group.propertyType})`,
|
||||||
|
data: values,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}20`,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointBackgroundColor: color,
|
||||||
|
pointBorderColor: '#ffffff',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
trend: trend,
|
||||||
|
totalValue: group.data.reduce((sum, item) => sum + (item[metric] || 0), 0),
|
||||||
|
averageValue: validValues.length > 0 ? validValues.reduce((sum, val) => sum + val, 0) / validValues.length : 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { labels, datasets }
|
||||||
|
}, [data, metric, normalizeData])
|
||||||
|
|
||||||
|
const renderChart = () => {
|
||||||
|
const options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 20,
|
||||||
|
font: {
|
||||||
|
size: 12,
|
||||||
|
weight: '500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
titleColor: 'white',
|
||||||
|
bodyColor: 'white',
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
cornerRadius: 8,
|
||||||
|
displayColors: true,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const value = context.parsed.y
|
||||||
|
if (normalizeData && metric !== 'transactions') {
|
||||||
|
return `${context.dataset.label}: ${value.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
if (metric === 'value' || metric === 'average_price' || metric === 'price_per_sqft') {
|
||||||
|
return `${context.dataset.label}: AED ${value.toLocaleString()}`
|
||||||
|
}
|
||||||
|
return `${context.dataset.label}: ${value.toLocaleString()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
drawBorder: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
font: {
|
||||||
|
size: 11
|
||||||
|
},
|
||||||
|
color: '#6B7280',
|
||||||
|
maxRotation: 45,
|
||||||
|
minRotation: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
drawBorder: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: function(value) {
|
||||||
|
if (normalizeData && metric !== 'transactions') {
|
||||||
|
return `${value.toFixed(0)}%`
|
||||||
|
}
|
||||||
|
if (metric === 'value' || metric === 'average_price' || metric === 'price_per_sqft') {
|
||||||
|
return `AED ${value.toLocaleString()}`
|
||||||
|
}
|
||||||
|
return value.toLocaleString()
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
size: 11
|
||||||
|
},
|
||||||
|
color: '#6B7280'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
line: {
|
||||||
|
tension: 0.4,
|
||||||
|
borderWidth: 3
|
||||||
|
},
|
||||||
|
point: {
|
||||||
|
radius: 4,
|
||||||
|
hoverRadius: 6,
|
||||||
|
borderWidth: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (chartType) {
|
||||||
|
case 'bar':
|
||||||
|
return <Bar data={processedData} options={options} />
|
||||||
|
case 'scatter':
|
||||||
|
return <Scatter data={processedData} options={options} />
|
||||||
|
default:
|
||||||
|
return <Line data={processedData} options={options} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
// Export functionality would be implemented here
|
||||||
|
console.log('Exporting comparison data:', processedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-1">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
title="Export"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* Chart Type */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{chartTypes.map(type => (
|
||||||
|
<button
|
||||||
|
key={type.key}
|
||||||
|
onClick={() => setChartType(type.key)}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
chartType === type.key
|
||||||
|
? 'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
|
||||||
|
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
title={type.label}
|
||||||
|
>
|
||||||
|
<type.icon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metric Selection */}
|
||||||
|
<select
|
||||||
|
value={metric}
|
||||||
|
onChange={(e) => setMetric(e.target.value)}
|
||||||
|
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{availableMetrics.map(metricOption => (
|
||||||
|
<option key={metricOption.key} value={metricOption.key}>
|
||||||
|
{metricOption.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Add Comparison Button */}
|
||||||
|
<button
|
||||||
|
onClick={onAddComparison}
|
||||||
|
className="flex items-center space-x-1 px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span>Add Comparison</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Indicators */}
|
||||||
|
{showTrends && processedData.datasets.length > 0 && (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{processedData.datasets.slice(0, 3).map((dataset, index) => (
|
||||||
|
<div key={index} className="flex items-center space-x-1">
|
||||||
|
{dataset.trend >= 0 ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span className={`text-sm font-medium ${
|
||||||
|
dataset.trend >= 0 ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{Math.abs(dataset.trend).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 truncate max-w-20">
|
||||||
|
{dataset.label.split(' ')[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Panel */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Chart Settings</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={normalizeData}
|
||||||
|
onChange={(e) => setNormalizeData(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Normalize Data</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showTrends}
|
||||||
|
onChange={(e) => setShowTrends(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Show Trends</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div style={{ height: `${height}px` }}>
|
||||||
|
{processedData.labels.length > 0 ? (
|
||||||
|
renderChart()
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<BarChart3 className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
No Comparison Data
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
Add items to compare or adjust your filters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
{processedData.datasets.length > 0 && (
|
||||||
|
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Summary Statistics</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{processedData.datasets.map((dataset, index) => (
|
||||||
|
<div key={index} className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h5 className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{dataset.label}
|
||||||
|
</h5>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{dataset.trend >= 0 ? (
|
||||||
|
<ArrowUpRight className="h-3 w-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownRight className="h-3 w-3 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span className={`text-xs font-medium ${
|
||||||
|
dataset.trend >= 0 ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{Math.abs(dataset.trend).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{normalizeData && metric !== 'transactions'
|
||||||
|
? `${dataset.totalValue.toFixed(1)}%`
|
||||||
|
: metric === 'value' || metric === 'average_price' || metric === 'price_per_sqft'
|
||||||
|
? `AED ${dataset.totalValue.toLocaleString()}`
|
||||||
|
: dataset.totalValue.toLocaleString()
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Average:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{normalizeData && metric !== 'transactions'
|
||||||
|
? `${dataset.averageValue.toFixed(1)}%`
|
||||||
|
: metric === 'value' || metric === 'average_price' || metric === 'price_per_sqft'
|
||||||
|
? `AED ${dataset.averageValue.toLocaleString()}`
|
||||||
|
: dataset.averageValue.toLocaleString()
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ComparativeAnalysis
|
||||||
486
frontend/src/components/ExportSharing.jsx
Normal file
486
frontend/src/components/ExportSharing.jsx
Normal file
@ -0,0 +1,486 @@
|
|||||||
|
import React, { useState, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Share2,
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
|
Table,
|
||||||
|
Mail,
|
||||||
|
Link,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Settings,
|
||||||
|
Calendar,
|
||||||
|
Filter,
|
||||||
|
BarChart3,
|
||||||
|
MapPin,
|
||||||
|
TrendingUp,
|
||||||
|
Code
|
||||||
|
} from 'lucide-react'
|
||||||
|
import html2canvas from 'html2canvas'
|
||||||
|
import jsPDF from 'jspdf'
|
||||||
|
import { CSVLink } from 'react-csv'
|
||||||
|
import { saveAs } from 'file-saver'
|
||||||
|
|
||||||
|
const ExportSharing = ({
|
||||||
|
data = [],
|
||||||
|
chartRefs = {},
|
||||||
|
title = 'Export & Share',
|
||||||
|
onExport,
|
||||||
|
onShare
|
||||||
|
}) => {
|
||||||
|
const [exportFormat, setExportFormat] = useState('pdf')
|
||||||
|
const [exportType, setExportType] = useState('chart')
|
||||||
|
const [includeData, setIncludeData] = useState(true)
|
||||||
|
const [includeMetadata, setIncludeMetadata] = useState(true)
|
||||||
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
|
const [isSharing, setIsSharing] = useState(false)
|
||||||
|
const [shareUrl, setShareUrl] = useState('')
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
|
||||||
|
const exportFormats = [
|
||||||
|
{ key: 'pdf', label: 'PDF Report', icon: FileText, description: 'High-quality PDF with charts and data' },
|
||||||
|
{ key: 'png', label: 'PNG Image', icon: Image, description: 'Chart as high-resolution image' },
|
||||||
|
{ key: 'csv', label: 'CSV Data', icon: Table, description: 'Raw data in spreadsheet format' },
|
||||||
|
{ key: 'json', label: 'JSON Data', icon: FileText, description: 'Structured data for developers' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const exportTypes = [
|
||||||
|
{ key: 'chart', label: 'Charts Only', icon: BarChart3 },
|
||||||
|
{ key: 'data', label: 'Data Only', icon: Table },
|
||||||
|
{ key: 'full', label: 'Full Report', icon: FileText },
|
||||||
|
{ key: 'map', label: 'Map View', icon: MapPin }
|
||||||
|
]
|
||||||
|
|
||||||
|
const shareOptions = [
|
||||||
|
{ key: 'link', label: 'Generate Link', icon: Link, description: 'Create shareable link' },
|
||||||
|
{ key: 'email', label: 'Email Report', icon: Mail, description: 'Send via email' },
|
||||||
|
{ key: 'embed', label: 'Embed Code', icon: Code, description: 'Get embed code' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setIsExporting(true)
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0]
|
||||||
|
const filename = `dubai-analytics-${exportType}-${timestamp}`
|
||||||
|
|
||||||
|
switch (exportFormat) {
|
||||||
|
case 'pdf':
|
||||||
|
await exportToPDF(filename)
|
||||||
|
break
|
||||||
|
case 'png':
|
||||||
|
await exportToPNG(filename)
|
||||||
|
break
|
||||||
|
case 'csv':
|
||||||
|
exportToCSV(filename)
|
||||||
|
break
|
||||||
|
case 'json':
|
||||||
|
exportToJSON(filename)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error('Unsupported export format')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onExport) {
|
||||||
|
onExport({ format: exportFormat, type: exportType, filename })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export failed:', error)
|
||||||
|
alert('Export failed. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToPDF = async (filename) => {
|
||||||
|
const pdf = new jsPDF('l', 'mm', 'a4')
|
||||||
|
const pageWidth = pdf.internal.pageSize.getWidth()
|
||||||
|
const pageHeight = pdf.internal.pageSize.getHeight()
|
||||||
|
|
||||||
|
// Add title
|
||||||
|
pdf.setFontSize(20)
|
||||||
|
pdf.text('Dubai Real Estate Analytics Report', 20, 30)
|
||||||
|
|
||||||
|
// Add metadata
|
||||||
|
if (includeMetadata) {
|
||||||
|
pdf.setFontSize(12)
|
||||||
|
pdf.text(`Generated on: ${new Date().toLocaleDateString()}`, 20, 45)
|
||||||
|
pdf.text(`Data points: ${data.length}`, 20, 55)
|
||||||
|
}
|
||||||
|
|
||||||
|
let yPosition = 70
|
||||||
|
|
||||||
|
// Export charts
|
||||||
|
for (const [chartName, chartRef] of Object.entries(chartRefs)) {
|
||||||
|
if (chartRef && chartRef.current) {
|
||||||
|
try {
|
||||||
|
const canvas = await html2canvas(chartRef.current, {
|
||||||
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
backgroundColor: '#ffffff'
|
||||||
|
})
|
||||||
|
|
||||||
|
const imgData = canvas.toDataURL('image/png')
|
||||||
|
const imgWidth = pageWidth - 40
|
||||||
|
const imgHeight = (canvas.height * imgWidth) / canvas.width
|
||||||
|
|
||||||
|
// Check if we need a new page
|
||||||
|
if (yPosition + imgHeight > pageHeight - 20) {
|
||||||
|
pdf.addPage()
|
||||||
|
yPosition = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.addImage(imgData, 'PNG', 20, yPosition, imgWidth, imgHeight)
|
||||||
|
yPosition += imgHeight + 20
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to export chart ${chartName}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add data table if requested
|
||||||
|
if (includeData && data.length > 0) {
|
||||||
|
pdf.addPage()
|
||||||
|
pdf.setFontSize(16)
|
||||||
|
pdf.text('Data Summary', 20, 30)
|
||||||
|
|
||||||
|
yPosition = 50
|
||||||
|
const headers = Object.keys(data[0])
|
||||||
|
const colWidth = (pageWidth - 40) / headers.length
|
||||||
|
|
||||||
|
// Table headers
|
||||||
|
pdf.setFontSize(10)
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
pdf.text(header, 20 + (index * colWidth), yPosition)
|
||||||
|
})
|
||||||
|
|
||||||
|
yPosition += 10
|
||||||
|
|
||||||
|
// Table data (first 20 rows)
|
||||||
|
data.slice(0, 20).forEach((row, rowIndex) => {
|
||||||
|
if (yPosition > pageHeight - 20) {
|
||||||
|
pdf.addPage()
|
||||||
|
yPosition = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.forEach((header, colIndex) => {
|
||||||
|
const value = String(row[header] || '').substring(0, 20)
|
||||||
|
pdf.text(value, 20 + (colIndex * colWidth), yPosition)
|
||||||
|
})
|
||||||
|
yPosition += 10
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.save(`${filename}.pdf`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToPNG = async (filename) => {
|
||||||
|
const chartRef = Object.values(chartRefs)[0] // Export first chart
|
||||||
|
if (!chartRef || !chartRef.current) {
|
||||||
|
throw new Error('No chart to export')
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = await html2canvas(chartRef.current, {
|
||||||
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
backgroundColor: '#ffffff'
|
||||||
|
})
|
||||||
|
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
saveAs(blob, `${filename}.png`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToCSV = (filename) => {
|
||||||
|
// This will be handled by the CSVLink component
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToJSON = (filename) => {
|
||||||
|
const jsonData = {
|
||||||
|
metadata: {
|
||||||
|
generated: new Date().toISOString(),
|
||||||
|
dataPoints: data.length,
|
||||||
|
version: '1.0'
|
||||||
|
},
|
||||||
|
data: data
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' })
|
||||||
|
saveAs(blob, `${filename}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShare = async (type) => {
|
||||||
|
setIsSharing(true)
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case 'link':
|
||||||
|
const url = await generateShareableLink()
|
||||||
|
setShareUrl(url)
|
||||||
|
break
|
||||||
|
case 'email':
|
||||||
|
await shareViaEmail()
|
||||||
|
break
|
||||||
|
case 'embed':
|
||||||
|
await generateEmbedCode()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error('Unsupported share type')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onShare) {
|
||||||
|
onShare({ type, data })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Share failed:', error)
|
||||||
|
alert('Share failed. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setIsSharing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateShareableLink = async () => {
|
||||||
|
// In a real app, this would generate a server-side shareable link
|
||||||
|
const baseUrl = window.location.origin
|
||||||
|
const shareId = Math.random().toString(36).substring(7)
|
||||||
|
const url = `${baseUrl}/share/${shareId}`
|
||||||
|
|
||||||
|
// Simulate API call
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareViaEmail = async () => {
|
||||||
|
const subject = 'Dubai Real Estate Analytics Report'
|
||||||
|
const body = `Please find the attached analytics report generated on ${new Date().toLocaleDateString()}.`
|
||||||
|
const mailtoUrl = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
|
||||||
|
|
||||||
|
window.open(mailtoUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateEmbedCode = async () => {
|
||||||
|
const embedCode = `<iframe src="${window.location.href}" width="800" height="600" frameborder="0"></iframe>`
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
await navigator.clipboard.writeText(embedCode)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async (text) => {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvData = data.map(item => ({
|
||||||
|
...item,
|
||||||
|
date: new Date(item.date).toLocaleDateString()
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-1">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Export charts and data in various formats
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
title="Export Settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Export Section */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Export Options
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Export Format */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Export Format
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{exportFormats.map(format => (
|
||||||
|
<button
|
||||||
|
key={format.key}
|
||||||
|
onClick={() => setExportFormat(format.key)}
|
||||||
|
className={`p-3 rounded-lg border-2 transition-all ${
|
||||||
|
exportFormat === format.key
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<format.icon className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{format.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{format.description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export Type */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Content Type
|
||||||
|
</label>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{exportTypes.map(type => (
|
||||||
|
<button
|
||||||
|
key={type.key}
|
||||||
|
onClick={() => setExportType(type.key)}
|
||||||
|
className={`flex items-center space-x-2 px-3 py-2 rounded-lg border transition-all ${
|
||||||
|
exportType === type.key
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-gray-300 dark:hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<type.icon className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{type.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export Settings */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<h5 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Export Settings</h5>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeData}
|
||||||
|
onChange={(e) => setIncludeData(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Include raw data</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeMetadata}
|
||||||
|
onChange={(e) => setIncludeMetadata(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Include metadata</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Export Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center justify-center space-x-2"
|
||||||
|
>
|
||||||
|
{isExporting ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
<span>Exporting...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span>Export {exportFormats.find(f => f.key === exportFormat)?.label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* CSV Download Link */}
|
||||||
|
{exportFormat === 'csv' && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<CSVLink
|
||||||
|
data={csvData}
|
||||||
|
filename={`dubai-analytics-${new Date().toISOString().split('T')[0]}.csv`}
|
||||||
|
className="block w-full bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded-lg transition-colors text-center"
|
||||||
|
>
|
||||||
|
<Table className="h-4 w-4 inline mr-2" />
|
||||||
|
Download CSV
|
||||||
|
</CSVLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Share Section */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||||
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
|
Share Options
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{shareOptions.map(option => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
onClick={() => handleShare(option.key)}
|
||||||
|
disabled={isSharing}
|
||||||
|
className="p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 transition-all text-left disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<option.icon className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{option.label}</span>
|
||||||
|
</div>
|
||||||
|
{isSharing && (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{option.description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Share URL */}
|
||||||
|
{shareUrl && (
|
||||||
|
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Shareable Link
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={shareUrl}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(shareUrl)}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExportSharing
|
||||||
332
frontend/src/components/GeographicHeatMap.jsx
Normal file
332
frontend/src/components/GeographicHeatMap.jsx
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { MapContainer, TileLayer, CircleMarker, Popup, Tooltip } from 'react-leaflet'
|
||||||
|
// import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import './MapStyles.css'
|
||||||
|
import { MapPin, TrendingUp, DollarSign, Building2 } from 'lucide-react'
|
||||||
|
|
||||||
|
// Fix for default markers in react-leaflet
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||||
|
})
|
||||||
|
|
||||||
|
const GeographicHeatMap = ({
|
||||||
|
data = [],
|
||||||
|
height = '500px',
|
||||||
|
onAreaClick,
|
||||||
|
showClusters = true,
|
||||||
|
colorScheme = 'viridis'
|
||||||
|
}) => {
|
||||||
|
const [mapCenter, setMapCenter] = useState([25.2048, 55.2708]) // Dubai coordinates
|
||||||
|
const [mapZoom, setMapZoom] = useState(11)
|
||||||
|
|
||||||
|
|
||||||
|
// Handle case where data might be an object with a results property
|
||||||
|
const actualData = data && typeof data === 'object' && !Array.isArray(data) && data.results
|
||||||
|
? data.results
|
||||||
|
: Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Dubai area coordinates mapping
|
||||||
|
const areaCoordinates = {
|
||||||
|
'Downtown Dubai': [25.1972, 55.2744],
|
||||||
|
'Dubai Marina': [25.0772, 55.1308],
|
||||||
|
'Jumeirah': [25.2100, 55.2600],
|
||||||
|
'Palm Jumeirah': [25.1124, 55.1390],
|
||||||
|
'Business Bay': [25.1881, 55.2653],
|
||||||
|
'DIFC': [25.2138, 55.2792],
|
||||||
|
'JBR': [25.0772, 55.1308],
|
||||||
|
'Dubai Hills': [25.1500, 55.3000],
|
||||||
|
'Arabian Ranches': [25.1000, 55.2000],
|
||||||
|
'Jumeirah Village': [25.1500, 55.2500],
|
||||||
|
'Dubai Silicon Oasis': [25.1167, 55.3833],
|
||||||
|
'Dubai Sports City': [25.0500, 55.2000],
|
||||||
|
'Dubai Investment Park': [25.0167, 55.2000],
|
||||||
|
'International City': [25.1333, 55.4000],
|
||||||
|
'Discovery Gardens': [25.0167, 55.2000],
|
||||||
|
'Jumeirah Lake Towers': [25.0667, 55.1500],
|
||||||
|
'Dubai Healthcare City': [25.2333, 55.3000],
|
||||||
|
'Dubai International Financial Centre': [25.2138, 55.2792],
|
||||||
|
'Dubai Creek Harbour': [25.2000, 55.3500],
|
||||||
|
'Dubai Hills Estate': [25.1500, 55.3000],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color schemes for different metrics
|
||||||
|
const colorSchemes = {
|
||||||
|
viridis: ['#440154', '#482777', '#3f4a8a', '#31678e', '#26838f', '#1f9d8a', '#6cce5a', '#b6de2b', '#fee825'],
|
||||||
|
plasma: ['#0d0887', '#46039f', '#7201a8', '#9c179e', '#bd3786', '#d8576b', '#ed7953', '#fb9f3a', '#fdca26'],
|
||||||
|
inferno: ['#000004', '#1b0c42', '#4a0c6b', '#781c6d', '#a52c60', '#cf4446', '#ed6925', '#fb9a06', '#fcce25'],
|
||||||
|
magma: ['#000004', '#1d1147', '#51127c', '#822681', '#b63679', '#e65164', '#fb8861', '#fec287', '#fcfdbf'],
|
||||||
|
cool: ['#003f5c', '#2e4a62', '#4d5568', '#6c606e', '#8b6b74', '#aa767a', '#c98180', '#e88c86', '#ff9a8c'],
|
||||||
|
hot: ['#000000', '#330000', '#660000', '#990000', '#cc0000', '#ff0000', '#ff3300', '#ff6600', '#ff9900']
|
||||||
|
}
|
||||||
|
|
||||||
|
const getColorForValue = (value, maxValue, minValue = 0) => {
|
||||||
|
const colors = colorSchemes[colorScheme] || colorSchemes.viridis
|
||||||
|
const normalizedValue = (value - minValue) / (maxValue - minValue)
|
||||||
|
const colorIndex = Math.floor(normalizedValue * (colors.length - 1))
|
||||||
|
return colors[Math.max(0, Math.min(colorIndex, colors.length - 1))]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRadiusForValue = (value, maxValue, minValue = 0) => {
|
||||||
|
const minRadius = 5
|
||||||
|
const maxRadius = 25
|
||||||
|
const normalizedValue = (value - minValue) / (maxValue - minValue)
|
||||||
|
return minRadius + (normalizedValue * (maxRadius - minRadius))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process data to get min/max values for normalization
|
||||||
|
const processedData = Array.isArray(actualData) ? actualData.map(item => {
|
||||||
|
if (!item) return null
|
||||||
|
const coords = areaCoordinates[item.area_en] || [25.2048 + (Math.random() - 0.5) * 0.1, 55.2708 + (Math.random() - 0.5) * 0.1]
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
coordinates: coords,
|
||||||
|
value: item.transaction_count || item.total_value || item.average_price || 0
|
||||||
|
}
|
||||||
|
}).filter(Boolean) : []
|
||||||
|
|
||||||
|
const maxValue = processedData.length > 0 ? Math.max(...processedData.map(item => item.value)) : 0
|
||||||
|
const minValue = processedData.length > 0 ? Math.min(...processedData.map(item => item.value)) : 0
|
||||||
|
|
||||||
|
const formatValue = (value, type = 'count') => {
|
||||||
|
if (type === 'price') {
|
||||||
|
return `AED ${typeof value === 'number' ? value.toLocaleString() : '0'}`
|
||||||
|
} else if (type === 'count') {
|
||||||
|
return typeof value === 'number' ? value.toLocaleString() : '0'
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMetricType = () => {
|
||||||
|
if (!actualData || !Array.isArray(actualData) || actualData.length === 0) return 'count'
|
||||||
|
const firstItem = actualData[0]
|
||||||
|
if (firstItem && firstItem.total_value) return 'price'
|
||||||
|
if (firstItem && firstItem.average_price) return 'price'
|
||||||
|
return 'count'
|
||||||
|
}
|
||||||
|
|
||||||
|
const metricType = getMetricType()
|
||||||
|
|
||||||
|
// Show loading state if data is not ready
|
||||||
|
if (!actualData || actualData === undefined) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Geographic Heat Map
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Property activity distribution across Dubai areas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ height }} className="flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Loading map data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Geographic Heat Map
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Property activity distribution across Dubai areas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-blue-500"></div>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">Low Activity</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">Medium</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">High Activity</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height }} className="relative">
|
||||||
|
{processedData.length > 0 ? (
|
||||||
|
<MapContainer
|
||||||
|
center={mapCenter}
|
||||||
|
zoom={mapZoom}
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
className="z-0"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Temporarily disabled clustering */}
|
||||||
|
{false && showClusters ? (
|
||||||
|
<MarkerClusterGroup
|
||||||
|
chunkedLoading
|
||||||
|
spiderfyOnMaxZoom
|
||||||
|
showCoverageOnHover
|
||||||
|
zoomToBoundsOnClick
|
||||||
|
maxClusterRadius={50}
|
||||||
|
style={{
|
||||||
|
fillColor: '#3b82f6',
|
||||||
|
color: '#1e40af',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.7
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processedData.map((item, index) => (
|
||||||
|
<CircleMarker
|
||||||
|
key={index}
|
||||||
|
center={item.coordinates}
|
||||||
|
radius={getRadiusForValue(item.value, maxValue, minValue)}
|
||||||
|
pathOptions={{
|
||||||
|
fillColor: getColorForValue(item.value, maxValue, minValue),
|
||||||
|
color: '#ffffff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 0.8,
|
||||||
|
fillOpacity: 0.7
|
||||||
|
}}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => onAreaClick && onAreaClick(item)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="p-2 min-w-[200px]">
|
||||||
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
|
<MapPin className="h-4 w-4 text-blue-500" />
|
||||||
|
<h4 className="font-semibold text-gray-900">{item.area_en}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600">Transactions:</span>
|
||||||
|
<span className="font-medium">{formatValue(item.transaction_count || 0, 'count')}</span>
|
||||||
|
</div>
|
||||||
|
{item.total_value && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600">Total Value:</span>
|
||||||
|
<span className="font-medium">{formatValue(item.total_value, 'price')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.average_price && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600">Avg Price:</span>
|
||||||
|
<span className="font-medium">{formatValue(item.average_price, 'price')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.price_per_sqft && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600">Price/Sqft:</span>
|
||||||
|
<span className="font-medium">AED {typeof item.price_per_sqft === 'number' ? item.price_per_sqft.toFixed(2) : '0'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
<Tooltip direction="top" offset={[0, -10]} opacity={1}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-semibold">{item.area_en}</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{formatValue(item.value, metricType)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</CircleMarker>
|
||||||
|
))}
|
||||||
|
</MarkerClusterGroup>
|
||||||
|
) : (
|
||||||
|
processedData.map((item, index) => (
|
||||||
|
<CircleMarker
|
||||||
|
key={index}
|
||||||
|
center={item.coordinates}
|
||||||
|
radius={getRadiusForValue(item.value, maxValue, minValue)}
|
||||||
|
pathOptions={{
|
||||||
|
fillColor: getColorForValue(item.value, maxValue, minValue),
|
||||||
|
color: '#ffffff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 0.8,
|
||||||
|
fillOpacity: 0.7
|
||||||
|
}}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => onAreaClick && onAreaClick(item)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="p-2 min-w-[200px]">
|
||||||
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
|
<MapPin className="h-4 w-4 text-blue-500" />
|
||||||
|
<h4 className="font-semibold text-gray-900">{item.area_en}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600">Transactions:</span>
|
||||||
|
<span className="font-medium">{formatValue(item.transaction_count || 0, 'count')}</span>
|
||||||
|
</div>
|
||||||
|
{item.total_value && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600">Total Value:</span>
|
||||||
|
<span className="font-medium">{formatValue(item.total_value, 'price')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.average_price && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-600">Avg Price:</span>
|
||||||
|
<span className="font-medium">{formatValue(item.average_price, 'price')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</CircleMarker>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<MapPin className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
No Geographic Data
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{!Array.isArray(actualData)
|
||||||
|
? 'Data format error - expected array but received: ' + typeof actualData
|
||||||
|
: 'No location data available for the selected filters'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{!Array.isArray(actualData) && (
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
Debug: {JSON.stringify(actualData)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GeographicHeatMap
|
||||||
206
frontend/src/components/MapStyles.css
Normal file
206
frontend/src/components/MapStyles.css
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
/* Leaflet Map Styles */
|
||||||
|
.leaflet-container {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
border-top-color: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
border-left-color: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
border-right-color: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Marker Cluster Styles */
|
||||||
|
.marker-cluster-small {
|
||||||
|
background-color: rgba(59, 130, 246, 0.6);
|
||||||
|
border: 2px solid rgba(59, 130, 246, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-cluster-small div {
|
||||||
|
background-color: rgba(59, 130, 246, 0.8);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-cluster-medium {
|
||||||
|
background-color: rgba(16, 185, 129, 0.6);
|
||||||
|
border: 2px solid rgba(16, 185, 129, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-cluster-medium div {
|
||||||
|
background-color: rgba(16, 185, 129, 0.8);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-cluster-large {
|
||||||
|
background-color: rgba(245, 158, 11, 0.6);
|
||||||
|
border: 2px solid rgba(245, 158, 11, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-cluster-large div {
|
||||||
|
background-color: rgba(245, 158, 11, 0.8);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom marker styles */
|
||||||
|
.custom-marker {
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map controls */
|
||||||
|
.leaflet-control-zoom a {
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control-zoom a:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode map styles */
|
||||||
|
.dark .leaflet-container {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .leaflet-popup-content-wrapper {
|
||||||
|
background: #374151;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .leaflet-popup-tip {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .leaflet-control-zoom a {
|
||||||
|
background-color: #374151;
|
||||||
|
border-color: #4b5563;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .leaflet-control-zoom a:hover {
|
||||||
|
background-color: #4b5563;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for markers */
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.pulse {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom popup styles */
|
||||||
|
.custom-popup {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-popup .popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-popup .popup-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-popup .popup-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-popup .popup-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-popup .popup-label {
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-popup .popup-value {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode popup styles */
|
||||||
|
.dark .custom-popup .popup-header {
|
||||||
|
border-bottom-color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .custom-popup .popup-title {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .custom-popup .popup-label {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .custom-popup .popup-value {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
518
frontend/src/components/TimeSeriesChart.jsx
Normal file
518
frontend/src/components/TimeSeriesChart.jsx
Normal file
@ -0,0 +1,518 @@
|
|||||||
|
import React, { useState, useMemo } from 'react'
|
||||||
|
import { Line, Bar } from 'react-chartjs-2'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
TimeScale,
|
||||||
|
} from 'chart.js'
|
||||||
|
import 'chartjs-adapter-date-fns'
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
BarChart3,
|
||||||
|
LineChart,
|
||||||
|
Download,
|
||||||
|
Maximize2,
|
||||||
|
Settings,
|
||||||
|
Calendar,
|
||||||
|
Filter
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
TimeScale
|
||||||
|
)
|
||||||
|
|
||||||
|
const TimeSeriesChart = ({
|
||||||
|
data = [],
|
||||||
|
type = 'line',
|
||||||
|
height = 400,
|
||||||
|
title = 'Time Series Analysis',
|
||||||
|
subtitle = '',
|
||||||
|
showControls = true,
|
||||||
|
showTrends = true,
|
||||||
|
showForecast = false,
|
||||||
|
forecastData = [],
|
||||||
|
onExport,
|
||||||
|
onFullscreen
|
||||||
|
}) => {
|
||||||
|
const [chartType, setChartType] = useState(type)
|
||||||
|
const [timeRange, setTimeRange] = useState('all')
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [chartSettings, setChartSettings] = useState({
|
||||||
|
showGrid: true,
|
||||||
|
showLegend: true,
|
||||||
|
showDataLabels: false,
|
||||||
|
smoothLines: true,
|
||||||
|
fillArea: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process data based on time range
|
||||||
|
const processedData = useMemo(() => {
|
||||||
|
if (!data || data.length === 0) return { labels: [], datasets: [] }
|
||||||
|
|
||||||
|
let filteredData = [...data]
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
// Apply time range filter
|
||||||
|
switch (timeRange) {
|
||||||
|
case '7d':
|
||||||
|
filteredData = data.filter(item =>
|
||||||
|
new Date(item.date) >= new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case '30d':
|
||||||
|
filteredData = data.filter(item =>
|
||||||
|
new Date(item.date) >= new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case '90d':
|
||||||
|
filteredData = data.filter(item =>
|
||||||
|
new Date(item.date) >= new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case '1y':
|
||||||
|
filteredData = data.filter(item =>
|
||||||
|
new Date(item.date) >= new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
filteredData = data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date
|
||||||
|
filteredData.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||||
|
|
||||||
|
const labels = filteredData.map(item =>
|
||||||
|
new Date(item.date).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
...(timeRange === '1y' || timeRange === 'all' ? { year: '2-digit' } : {})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calculate trends
|
||||||
|
const calculateTrend = (values) => {
|
||||||
|
if (values.length < 2) return 0
|
||||||
|
const first = values[0]
|
||||||
|
const last = values[values.length - 1]
|
||||||
|
return ((last - first) / first) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionValues = filteredData.map(item => item.transactions || item.value || 0)
|
||||||
|
const valueValues = filteredData.map(item => item.value || 0)
|
||||||
|
|
||||||
|
const transactionTrend = calculateTrend(transactionValues)
|
||||||
|
const valueTrend = calculateTrend(valueValues)
|
||||||
|
|
||||||
|
const datasets = [
|
||||||
|
{
|
||||||
|
label: 'Transactions',
|
||||||
|
data: transactionValues,
|
||||||
|
borderColor: 'rgb(59, 130, 246)',
|
||||||
|
backgroundColor: chartSettings.fillArea ? 'rgba(59, 130, 246, 0.1)' : 'transparent',
|
||||||
|
tension: chartSettings.smoothLines ? 0.4 : 0,
|
||||||
|
fill: chartSettings.fillArea,
|
||||||
|
pointRadius: chartSettings.showDataLabels ? 4 : 0,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointBackgroundColor: 'rgb(59, 130, 246)',
|
||||||
|
pointBorderColor: '#ffffff',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
trend: transactionTrend
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add value dataset if available
|
||||||
|
if (filteredData.some(item => item.value)) {
|
||||||
|
datasets.push({
|
||||||
|
label: 'Value (AED)',
|
||||||
|
data: valueValues.map(val => val / 1000000), // Convert to millions
|
||||||
|
borderColor: 'rgb(16, 185, 129)',
|
||||||
|
backgroundColor: chartSettings.fillArea ? 'rgba(16, 185, 129, 0.1)' : 'transparent',
|
||||||
|
tension: chartSettings.smoothLines ? 0.4 : 0,
|
||||||
|
fill: chartSettings.fillArea,
|
||||||
|
pointRadius: chartSettings.showDataLabels ? 4 : 0,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointBackgroundColor: 'rgb(16, 185, 129)',
|
||||||
|
pointBorderColor: '#ffffff',
|
||||||
|
pointBorderWidth: 2,
|
||||||
|
yAxisID: 'y1',
|
||||||
|
trend: valueTrend
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add forecast data if available
|
||||||
|
if (showForecast && forecastData.length > 0) {
|
||||||
|
const forecastLabels = forecastData.map(item =>
|
||||||
|
new Date(item.date).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
...(timeRange === '1y' || timeRange === 'all' ? { year: '2-digit' } : {})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
label: 'Forecast',
|
||||||
|
data: forecastData.map(item => item.predicted_value || item.value || 0),
|
||||||
|
borderColor: 'rgb(168, 85, 247)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
tension: 0.4,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointBackgroundColor: 'rgb(168, 85, 247)',
|
||||||
|
pointBorderColor: '#ffffff',
|
||||||
|
pointBorderWidth: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: [...labels, ...forecastLabels],
|
||||||
|
datasets: datasets.map(dataset => ({
|
||||||
|
...dataset,
|
||||||
|
data: [...dataset.data, ...(dataset.label === 'Forecast' ?
|
||||||
|
forecastData.map(item => item.predicted_value || item.value || 0) :
|
||||||
|
new Array(forecastData.length).fill(null)
|
||||||
|
)]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { labels, datasets }
|
||||||
|
}, [data, timeRange, chartSettings, showForecast, forecastData])
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: chartSettings.showLegend,
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 20,
|
||||||
|
font: {
|
||||||
|
size: 12,
|
||||||
|
weight: '500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
titleColor: 'white',
|
||||||
|
bodyColor: 'white',
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
cornerRadius: 8,
|
||||||
|
displayColors: true,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
if (context.datasetIndex === 1) {
|
||||||
|
return `${context.dataset.label}: AED ${context.parsed.y.toFixed(1)}M`
|
||||||
|
}
|
||||||
|
return `${context.dataset.label}: ${context.parsed.y.toLocaleString()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
grid: {
|
||||||
|
display: chartSettings.showGrid,
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
drawBorder: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
font: {
|
||||||
|
size: 11
|
||||||
|
},
|
||||||
|
color: '#6B7280',
|
||||||
|
maxRotation: 45,
|
||||||
|
minRotation: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
display: chartSettings.showGrid,
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
drawBorder: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: function(value) {
|
||||||
|
return value.toLocaleString()
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
size: 11
|
||||||
|
},
|
||||||
|
color: '#6B7280'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: function(value) {
|
||||||
|
return `AED ${value.toFixed(1)}M`
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
size: 11
|
||||||
|
},
|
||||||
|
color: '#6B7280'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
line: {
|
||||||
|
tension: chartSettings.smoothLines ? 0.4 : 0,
|
||||||
|
borderWidth: 3
|
||||||
|
},
|
||||||
|
point: {
|
||||||
|
radius: chartSettings.showDataLabels ? 4 : 0,
|
||||||
|
hoverRadius: 6,
|
||||||
|
borderWidth: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (onExport) {
|
||||||
|
onExport(processedData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFullscreen = () => {
|
||||||
|
if (onFullscreen) {
|
||||||
|
onFullscreen(processedData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderChart = () => {
|
||||||
|
if (chartType === 'bar') {
|
||||||
|
return <Bar data={processedData} options={chartOptions} />
|
||||||
|
}
|
||||||
|
return <Line data={processedData} options={chartOptions} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-1">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showControls && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
title="Chart Settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
title="Export Chart"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleFullscreen}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
title="Fullscreen"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Calendar className="h-4 w-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={timeRange}
|
||||||
|
onChange={(e) => setTimeRange(e.target.value)}
|
||||||
|
className="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="7d">Last 7 days</option>
|
||||||
|
<option value="30d">Last 30 days</option>
|
||||||
|
<option value="90d">Last 90 days</option>
|
||||||
|
<option value="1y">Last year</option>
|
||||||
|
<option value="all">All time</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setChartType('line')}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
chartType === 'line'
|
||||||
|
? 'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
|
||||||
|
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
title="Line Chart"
|
||||||
|
>
|
||||||
|
<LineChart className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChartType('bar')}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
chartType === 'bar'
|
||||||
|
? 'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
|
||||||
|
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
title="Bar Chart"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Indicators */}
|
||||||
|
{showTrends && processedData.datasets.length > 0 && (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{processedData.datasets.map((dataset, index) => (
|
||||||
|
dataset.trend !== undefined && (
|
||||||
|
<div key={index} className="flex items-center space-x-1">
|
||||||
|
{dataset.trend >= 0 ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span className={`text-sm font-medium ${
|
||||||
|
dataset.trend >= 0 ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{Math.abs(dataset.trend).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">{dataset.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Panel */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="mt-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Chart Settings</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chartSettings.showGrid}
|
||||||
|
onChange={(e) => setChartSettings(prev => ({ ...prev, showGrid: e.target.checked }))}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Show Grid</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chartSettings.showLegend}
|
||||||
|
onChange={(e) => setChartSettings(prev => ({ ...prev, showLegend: e.target.checked }))}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Show Legend</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chartSettings.showDataLabels}
|
||||||
|
onChange={(e) => setChartSettings(prev => ({ ...prev, showDataLabels: e.target.checked }))}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Show Data Points</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chartSettings.smoothLines}
|
||||||
|
onChange={(e) => setChartSettings(prev => ({ ...prev, smoothLines: e.target.checked }))}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Smooth Lines</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chartSettings.fillArea}
|
||||||
|
onChange={(e) => setChartSettings(prev => ({ ...prev, fillArea: e.target.checked }))}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Fill Area</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div style={{ height: `${height}px` }}>
|
||||||
|
{processedData.labels.length > 0 ? (
|
||||||
|
renderChart()
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<TrendingUp className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
No Time Series Data
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
No data available for the selected time period
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimeSeriesChart
|
||||||
@ -33,9 +33,9 @@ export const AuthProvider = ({ children }) => {
|
|||||||
try {
|
try {
|
||||||
// Verify token with backend
|
// Verify token with backend
|
||||||
const response = await api.get('/auth/user/')
|
const response = await api.get('/auth/user/')
|
||||||
if (response.data) {
|
if (response) {
|
||||||
dispatch(setCredentials({
|
dispatch(setCredentials({
|
||||||
user: response.data,
|
user: response,
|
||||||
token
|
token
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
@ -63,13 +63,13 @@ export const AuthProvider = ({ children }) => {
|
|||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
try {
|
try {
|
||||||
const refreshResponse = await api.post('/auth/refresh/', { refresh: refreshToken })
|
const refreshResponse = await api.post('/auth/refresh/', { refresh: refreshToken })
|
||||||
const newToken = refreshResponse.data.access
|
const newToken = refreshResponse.access
|
||||||
localStorage.setItem('accessToken', newToken)
|
localStorage.setItem('accessToken', newToken)
|
||||||
|
|
||||||
// Get user data with new token
|
// Get user data with new token
|
||||||
const userResponse = await api.get('/auth/user/')
|
const userResponse = await api.get('/auth/user/')
|
||||||
dispatch(setCredentials({
|
dispatch(setCredentials({
|
||||||
user: userResponse.data,
|
user: userResponse,
|
||||||
token: newToken
|
token: newToken
|
||||||
}))
|
}))
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
@ -108,7 +108,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
const login = async (email, password) => {
|
const login = async (email, password) => {
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/auth/login/', { email, password })
|
const response = await api.post('/auth/login/', { email, password })
|
||||||
const { user, tokens } = response.data
|
const { user, tokens } = response
|
||||||
|
|
||||||
localStorage.setItem('accessToken', tokens.access)
|
localStorage.setItem('accessToken', tokens.access)
|
||||||
localStorage.setItem('refreshToken', tokens.refresh)
|
localStorage.setItem('refreshToken', tokens.refresh)
|
||||||
@ -119,7 +119,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error.response?.data?.message || 'Login failed'
|
error: error.response?.data?.message || error.message || 'Login failed'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,37 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-delay-1 {
|
||||||
|
animation: fade-in 0.6s ease-out 0.1s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-delay-2 {
|
||||||
|
animation: fade-in 0.6s ease-out 0.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-delay-3 {
|
||||||
|
animation: fade-in 0.6s ease-out 0.3s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-delay-4 {
|
||||||
|
animation: fade-in 0.6s ease-out 0.4s both;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--toast-bg: #ffffff;
|
--toast-bg: #ffffff;
|
||||||
--toast-color: #1f2937;
|
--toast-color: #1f2937;
|
||||||
@ -25,15 +56,15 @@
|
|||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-white dark:ring-offset-gray-900;
|
@apply inline-flex items-center justify-center rounded-lg text-sm font-semibold transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-white dark:ring-offset-gray-900 shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
@apply bg-gradient-to-r from-blue-600 to-blue-700 text-white hover:from-blue-700 hover:to-blue-800 focus:ring-blue-500 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 active:translate-y-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply bg-secondary-100 text-secondary-900 hover:bg-secondary-200 focus:ring-secondary-500 dark:bg-secondary-800 dark:text-secondary-100 dark:hover:bg-secondary-700;
|
@apply bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@ -41,15 +72,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
@apply h-8 px-3 text-xs;
|
@apply h-9 px-4 text-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-md {
|
.btn-md {
|
||||||
@apply h-10 px-4 py-2;
|
@apply h-11 px-6 py-2.5 text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-lg {
|
.btn-lg {
|
||||||
@apply h-12 px-8 text-base;
|
@apply h-12 px-8 text-base py-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@ -7,11 +7,24 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Filter,
|
Filter,
|
||||||
Download,
|
Download,
|
||||||
RefreshCw
|
RefreshCw,
|
||||||
|
Globe,
|
||||||
|
BarChart,
|
||||||
|
PieChart,
|
||||||
|
Activity,
|
||||||
|
Share2,
|
||||||
|
Settings,
|
||||||
|
Maximize2,
|
||||||
|
Calendar,
|
||||||
|
Layers
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { analyticsAPI } from '../services/api'
|
import { analyticsAPI } from '../services/api'
|
||||||
import Chart from '../components/Chart'
|
import Chart from '../components/Chart'
|
||||||
import StatCard from '../components/StatCard'
|
import StatCard from '../components/StatCard'
|
||||||
|
import GeographicHeatMap from '../components/GeographicHeatMap'
|
||||||
|
import TimeSeriesChart from '../components/TimeSeriesChart'
|
||||||
|
import ComparativeAnalysis from '../components/ComparativeAnalysis'
|
||||||
|
import ExportSharing from '../components/ExportSharing'
|
||||||
|
|
||||||
const Analytics = () => {
|
const Analytics = () => {
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
@ -21,6 +34,15 @@ const Analytics = () => {
|
|||||||
end_date: '',
|
end_date: '',
|
||||||
})
|
})
|
||||||
const [activeTab, setActiveTab] = useState('overview')
|
const [activeTab, setActiveTab] = useState('overview')
|
||||||
|
const [comparisonItems, setComparisonItems] = useState([])
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false)
|
||||||
|
const [selectedVisualization, setSelectedVisualization] = useState('all')
|
||||||
|
|
||||||
|
// Refs for export functionality
|
||||||
|
const chartRefs = useRef({})
|
||||||
|
const mapRef = useRef(null)
|
||||||
|
const timeSeriesRef = useRef(null)
|
||||||
|
const comparisonRef = useRef(null)
|
||||||
|
|
||||||
const { data: summary, isLoading: summaryLoading } = useQuery({
|
const { data: summary, isLoading: summaryLoading } = useQuery({
|
||||||
queryKey: ['transactionSummary', filters],
|
queryKey: ['transactionSummary', filters],
|
||||||
@ -70,15 +92,25 @@ const Analytics = () => {
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'overview', name: 'Overview', icon: BarChart3 },
|
{ id: 'overview', name: 'Overview', icon: BarChart3 },
|
||||||
|
{ id: 'geographic', name: 'Geographic', icon: Globe },
|
||||||
|
{ id: 'timeseries', name: 'Time Series', icon: TrendingUp },
|
||||||
|
{ id: 'comparative', name: 'Comparative', icon: BarChart },
|
||||||
{ id: 'areas', name: 'Areas', icon: MapPin },
|
{ id: 'areas', name: 'Areas', icon: MapPin },
|
||||||
{ id: 'properties', name: 'Properties', icon: Building2 },
|
{ id: 'properties', name: 'Properties', icon: Building2 },
|
||||||
{ id: 'trends', name: 'Trends', icon: TrendingUp },
|
{ id: 'export', name: 'Export & Share', icon: Share2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const visualizationTypes = [
|
||||||
|
{ id: 'all', name: 'All Visualizations', icon: Layers },
|
||||||
|
{ id: 'charts', name: 'Charts Only', icon: BarChart3 },
|
||||||
|
{ id: 'maps', name: 'Maps Only', icon: Globe },
|
||||||
|
{ id: 'tables', name: 'Tables Only', icon: Building2 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const summaryStats = summary ? [
|
const summaryStats = summary ? [
|
||||||
{
|
{
|
||||||
title: 'Total Transactions',
|
title: 'Total Transactions',
|
||||||
value: summary.total_transactions?.toLocaleString() || '0',
|
value: typeof summary.total_transactions === 'number' ? summary.total_transactions.toLocaleString() : '0',
|
||||||
change: '+12%',
|
change: '+12%',
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
@ -86,7 +118,7 @@ const Analytics = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total Value',
|
title: 'Total Value',
|
||||||
value: `AED ${summary.total_value?.toLocaleString() || '0'}`,
|
value: `AED ${typeof summary.total_value === 'number' ? summary.total_value.toLocaleString() : '0'}`,
|
||||||
change: '+8%',
|
change: '+8%',
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
@ -94,7 +126,7 @@ const Analytics = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Average Price',
|
title: 'Average Price',
|
||||||
value: `AED ${summary.average_price?.toLocaleString() || '0'}`,
|
value: `AED ${typeof summary.average_price === 'number' ? summary.average_price.toLocaleString() : '0'}`,
|
||||||
change: '+5%',
|
change: '+5%',
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
@ -102,7 +134,7 @@ const Analytics = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Price per Sqft',
|
title: 'Price per Sqft',
|
||||||
value: `AED ${summary.average_price_per_sqft?.toFixed(2) || '0'}`,
|
value: `AED ${typeof summary.average_price_per_sqft === 'number' ? summary.average_price_per_sqft.toFixed(2) : '0'}`,
|
||||||
change: '+3%',
|
change: '+3%',
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: MapPin,
|
icon: MapPin,
|
||||||
@ -113,24 +145,44 @@ const Analytics = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="bg-gradient-to-r from-slate-800 via-slate-700 to-slate-600 rounded-2xl p-6 text-white shadow-xl">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
<div>
|
||||||
Analytics
|
<h1 className="text-2xl font-bold mb-2 tracking-tight">
|
||||||
</h1>
|
Advanced Analytics
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
</h1>
|
||||||
Real estate market insights and trends
|
<p className="text-slate-200 text-base font-normal">
|
||||||
</p>
|
Comprehensive real estate market insights with interactive visualizations
|
||||||
</div>
|
</p>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center mt-4 space-x-6">
|
||||||
<button className="btn btn-secondary">
|
<div className="flex items-center space-x-2">
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||||
Export
|
<span className="text-sm font-medium">Live Data</span>
|
||||||
</button>
|
</div>
|
||||||
<button className="btn btn-primary">
|
<div className="flex items-center space-x-2">
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<Calendar className="h-4 w-4 text-slate-300" />
|
||||||
Refresh
|
<span className="text-sm font-medium">Last updated: {new Date().toLocaleTimeString()}</span>
|
||||||
</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="bg-white/10 backdrop-blur-sm hover:bg-white/20 transition-all duration-200 rounded-lg px-4 py-2 flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span>Export</span>
|
||||||
|
</button>
|
||||||
|
<button className="bg-white/10 backdrop-blur-sm hover:bg-white/20 transition-all duration-200 rounded-lg px-4 py-2 flex items-center space-x-2">
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
<button className="bg-white text-slate-700 hover:bg-slate-50 transition-all duration-200 rounded-lg px-4 py-2 flex items-center space-x-2 font-medium">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -180,13 +232,13 @@ const Analytics = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2 mt-4">
|
<div className="flex items-center space-x-3 mt-6">
|
||||||
<button onClick={handleApplyFilters} className="btn btn-primary">
|
<button onClick={handleApplyFilters} className="btn btn-primary btn-md">
|
||||||
<Filter className="h-4 w-4 mr-2" />
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
Apply Filters
|
Apply Filters
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleResetFilters} className="btn btn-secondary">
|
<button onClick={handleResetFilters} className="btn btn-secondary btn-md">
|
||||||
Reset
|
Reset Filters
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -221,29 +273,121 @@ const Analytics = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Visualization Type Selector */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="flex items-center justify-between">
|
||||||
<div className="card p-6">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
Visualizations
|
||||||
Transaction Volume Over Time
|
</h3>
|
||||||
</h3>
|
<div className="flex items-center space-x-2">
|
||||||
<Chart
|
{visualizationTypes.map(type => (
|
||||||
data={timeSeriesData}
|
<button
|
||||||
type="line"
|
key={type.id}
|
||||||
height={300}
|
onClick={() => setSelectedVisualization(type.id)}
|
||||||
/>
|
className={`flex items-center space-x-2 px-3 py-2 rounded-lg border transition-all ${
|
||||||
</div>
|
selectedVisualization === type.id
|
||||||
<div className="card p-6">
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
: 'border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-gray-300 dark:hover:border-gray-500'
|
||||||
Top Areas by Transactions
|
}`}
|
||||||
</h3>
|
>
|
||||||
<Chart
|
<type.icon className="h-4 w-4" />
|
||||||
data={areaStats}
|
<span className="text-sm font-medium">{type.name}</span>
|
||||||
type="bar"
|
</button>
|
||||||
height={300}
|
))}
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{(selectedVisualization === 'all' || selectedVisualization === 'charts') && (
|
||||||
|
<>
|
||||||
|
<div className="card p-6" ref={el => chartRefs.current['transactionVolume'] = el}>
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Transaction Volume Over Time
|
||||||
|
</h3>
|
||||||
|
<Chart
|
||||||
|
data={timeSeriesData}
|
||||||
|
type="line"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="card p-6" ref={el => chartRefs.current['areaStats'] = el}>
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Top Areas by Transactions
|
||||||
|
</h3>
|
||||||
|
<Chart
|
||||||
|
data={areaStats}
|
||||||
|
type="bar"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Geographic Map */}
|
||||||
|
{(selectedVisualization === 'all' || selectedVisualization === 'maps') && (
|
||||||
|
<div ref={mapRef}>
|
||||||
|
<GeographicHeatMap
|
||||||
|
data={areaStats || []}
|
||||||
|
height="500px"
|
||||||
|
onAreaClick={(area) => {
|
||||||
|
setFilters(prev => ({ ...prev, area: area.area_en }))
|
||||||
|
setActiveTab('areas')
|
||||||
|
}}
|
||||||
|
showClusters={true}
|
||||||
|
colorScheme="viridis"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'geographic' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<GeographicHeatMap
|
||||||
|
data={areaStats || []}
|
||||||
|
height="600px"
|
||||||
|
onAreaClick={(area) => {
|
||||||
|
setFilters(prev => ({ ...prev, area: area.area_en }))
|
||||||
|
}}
|
||||||
|
showClusters={true}
|
||||||
|
colorScheme="plasma"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'timeseries' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<TimeSeriesChart
|
||||||
|
data={timeSeriesData}
|
||||||
|
type="line"
|
||||||
|
height={500}
|
||||||
|
title="Market Trends Over Time"
|
||||||
|
subtitle="Transaction volume and value trends"
|
||||||
|
showControls={true}
|
||||||
|
showTrends={true}
|
||||||
|
showForecast={false}
|
||||||
|
onExport={(data) => console.log('Export time series:', data)}
|
||||||
|
onFullscreen={(data) => console.log('Fullscreen time series:', data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'comparative' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ComparativeAnalysis
|
||||||
|
data={timeSeriesData}
|
||||||
|
comparisonItems={comparisonItems}
|
||||||
|
onAddComparison={(item) => {
|
||||||
|
setComparisonItems(prev => [...prev, item])
|
||||||
|
}}
|
||||||
|
onRemoveComparison={(index) => {
|
||||||
|
setComparisonItems(prev => prev.filter((_, i) => i !== index))
|
||||||
|
}}
|
||||||
|
height={500}
|
||||||
|
title="Comparative Analysis"
|
||||||
|
subtitle="Compare different areas, property types, or time periods"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -269,8 +413,8 @@ const Analytics = () => {
|
|||||||
<tr key={index} className="table-row">
|
<tr key={index} className="table-row">
|
||||||
<td className="table-cell font-medium">{area.area}</td>
|
<td className="table-cell font-medium">{area.area}</td>
|
||||||
<td className="table-cell">{area.transaction_count}</td>
|
<td className="table-cell">{area.transaction_count}</td>
|
||||||
<td className="table-cell">AED {area.average_price?.toLocaleString()}</td>
|
<td className="table-cell">AED {typeof area.average_price === 'number' ? area.average_price.toLocaleString() : '0'}</td>
|
||||||
<td className="table-cell">AED {area.average_price_per_sqft?.toFixed(2)}</td>
|
<td className="table-cell">AED {typeof area.average_price_per_sqft === 'number' ? area.average_price_per_sqft.toFixed(2) : '0'}</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
area.price_trend === 'Rising'
|
area.price_trend === 'Rising'
|
||||||
@ -319,10 +463,10 @@ const Analytics = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="font-medium text-gray-900 dark:text-white">
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
AED {type.average_price?.toLocaleString()}
|
AED {typeof type.average_price === 'number' ? type.average_price.toLocaleString() : '0'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{type.market_share?.toFixed(1)}% market share
|
{typeof type.market_share === 'number' ? type.market_share.toFixed(1) : '0'}% market share
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -333,6 +477,28 @@ const Analytics = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'export' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ExportSharing
|
||||||
|
data={[
|
||||||
|
...(timeSeriesData || []),
|
||||||
|
...(areaStats || []),
|
||||||
|
...(propertyTypeStats || [])
|
||||||
|
]}
|
||||||
|
chartRefs={chartRefs}
|
||||||
|
title="Export & Share Analytics"
|
||||||
|
onExport={(exportData) => {
|
||||||
|
console.log('Export data:', exportData)
|
||||||
|
// Handle export logic here
|
||||||
|
}}
|
||||||
|
onShare={(shareData) => {
|
||||||
|
console.log('Share data:', shareData)
|
||||||
|
// Handle share logic here
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'trends' && (
|
{activeTab === 'trends' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="card p-6">
|
<div className="card p-6">
|
||||||
@ -346,8 +512,8 @@ const Analytics = () => {
|
|||||||
<h4 className="font-medium text-slate-900 dark:text-slate-100">Key Metrics</h4>
|
<h4 className="font-medium text-slate-900 dark:text-slate-100">Key Metrics</h4>
|
||||||
<div className="mt-2 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
<div className="mt-2 space-y-1 text-sm text-slate-700 dark:text-slate-300">
|
||||||
<p>Total Transactions: {marketAnalysis.key_metrics?.total_transactions}</p>
|
<p>Total Transactions: {marketAnalysis.key_metrics?.total_transactions}</p>
|
||||||
<p>Average Price: AED {marketAnalysis.key_metrics?.average_price?.toLocaleString()}</p>
|
<p>Average Price: AED {typeof marketAnalysis.key_metrics?.average_price === 'number' ? marketAnalysis.key_metrics.average_price.toLocaleString() : '0'}</p>
|
||||||
<p>Price Volatility: {marketAnalysis.key_metrics?.price_volatility?.toFixed(2)}</p>
|
<p>Price Volatility: {typeof marketAnalysis.key_metrics?.price_volatility === 'number' ? marketAnalysis.key_metrics.price_volatility.toFixed(2) : '0'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||||
|
|||||||
@ -142,6 +142,38 @@ const Dashboard = () => {
|
|||||||
placeholderData: [],
|
placeholderData: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Additional data queries for sample data analytics
|
||||||
|
const { data: brokerStats, isLoading: brokerStatsLoading, error: brokerStatsError } = useQuery({
|
||||||
|
queryKey: ['brokerStats'],
|
||||||
|
queryFn: () => analyticsAPI.getBrokerStats(),
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: projectStats, isLoading: projectStatsLoading, error: projectStatsError } = useQuery({
|
||||||
|
queryKey: ['projectStats'],
|
||||||
|
queryFn: () => analyticsAPI.getProjectStats(),
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: landStats, isLoading: landStatsLoading, error: landStatsError } = useQuery({
|
||||||
|
queryKey: ['landStats'],
|
||||||
|
queryFn: () => analyticsAPI.getLandStats(),
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: valuationStats, isLoading: valuationStatsLoading, error: valuationStatsError } = useQuery({
|
||||||
|
queryKey: ['valuationStats'],
|
||||||
|
queryFn: () => analyticsAPI.getValuationStats(),
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: rentStats, isLoading: rentStatsLoading, error: rentStatsError } = useQuery({
|
||||||
|
queryKey: ['rentStats'],
|
||||||
|
queryFn: () => analyticsAPI.getRentStats(),
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
function getDateRange(range) {
|
function getDateRange(range) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const start = new Date()
|
const start = new Date()
|
||||||
@ -226,37 +258,58 @@ const Dashboard = () => {
|
|||||||
const stats = [
|
const stats = [
|
||||||
{
|
{
|
||||||
title: 'Total Brokers',
|
title: 'Total Brokers',
|
||||||
value: '36,457',
|
value: brokerStats ?
|
||||||
change: '+2.3%',
|
(() => {
|
||||||
changeType: 'positive',
|
const total = brokerStats.gender_distribution?.reduce((sum, item) => sum + item.count, 0)
|
||||||
|
return typeof total === 'number' ? total.toLocaleString() : '0'
|
||||||
|
})()
|
||||||
|
: '...',
|
||||||
|
change: brokerStats?.gender_distribution?.find(item => item.gender === 'male')?.count ?
|
||||||
|
`${Math.round((brokerStats.gender_distribution.find(item => item.gender === 'male').count / brokerStats.gender_distribution.reduce((sum, item) => sum + item.count, 0)) * 100)}% male`
|
||||||
|
: '0%',
|
||||||
|
changeType: 'neutral',
|
||||||
icon: Briefcase,
|
icon: Briefcase,
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
loading: metricsLoading,
|
loading: brokerStatsLoading,
|
||||||
trend: '+2.3%',
|
|
||||||
trendIcon: ArrowUpRight
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Active Projects',
|
|
||||||
value: '10',
|
|
||||||
change: '+0%',
|
|
||||||
changeType: 'neutral',
|
|
||||||
icon: Building2,
|
|
||||||
color: 'green',
|
|
||||||
loading: metricsLoading,
|
|
||||||
trend: '0%',
|
trend: '0%',
|
||||||
trendIcon: TrendingUp
|
trendIcon: TrendingUp
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total Users',
|
title: 'Active Projects',
|
||||||
value: metrics?.total_users?.toLocaleString() || '2',
|
value: projectStats ?
|
||||||
change: '+100%',
|
(() => {
|
||||||
|
const count = projectStats.status_distribution?.find(item => item.project_status === 'ACTIVE')?.count
|
||||||
|
return typeof count === 'number' ? count.toLocaleString() : '0'
|
||||||
|
})()
|
||||||
|
: '...',
|
||||||
|
change: projectStats?.status_distribution?.length ?
|
||||||
|
`${projectStats.status_distribution.length} status types`
|
||||||
|
: '0',
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: Users,
|
icon: Building2,
|
||||||
color: 'purple',
|
color: 'green',
|
||||||
loading: metricsLoading,
|
loading: projectStatsLoading,
|
||||||
trend: '+100%',
|
trend: '0%',
|
||||||
trendIcon: ArrowUpRight
|
trendIcon: ArrowUpRight
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Total Lands',
|
||||||
|
value: landStats ?
|
||||||
|
(() => {
|
||||||
|
const total = landStats.type_distribution?.reduce((sum, item) => sum + item.count, 0)
|
||||||
|
return typeof total === 'number' ? total.toLocaleString() : '0'
|
||||||
|
})()
|
||||||
|
: '...',
|
||||||
|
change: landStats?.type_distribution?.length ?
|
||||||
|
`${landStats.type_distribution.length} land types`
|
||||||
|
: '0',
|
||||||
|
changeType: 'positive',
|
||||||
|
icon: MapPin,
|
||||||
|
color: 'purple',
|
||||||
|
loading: landStatsLoading,
|
||||||
|
trend: '0%',
|
||||||
|
trendIcon: TrendingUp
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'System Health',
|
title: 'System Health',
|
||||||
value: metrics?.system_health === 'healthy' ? '100%' : '95%',
|
value: metrics?.system_health === 'healthy' ? '100%' : '95%',
|
||||||
@ -271,7 +324,7 @@ const Dashboard = () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8 animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-gradient-to-r from-slate-800 via-slate-700 to-slate-600 rounded-2xl p-8 text-white shadow-2xl">
|
<div className="bg-gradient-to-r from-slate-800 via-slate-700 to-slate-600 rounded-2xl p-8 text-white shadow-2xl">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -295,32 +348,32 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="flex items-center space-x-2 bg-white/10 backdrop-blur-sm rounded-lg px-4 py-2">
|
<div className="flex items-center space-x-2 bg-slate-700/50 backdrop-blur-sm rounded-lg px-4 py-2 border border-slate-600">
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4 text-slate-300" />
|
||||||
<select
|
<select
|
||||||
value={timeRange}
|
value={timeRange}
|
||||||
onChange={(e) => setTimeRange(e.target.value)}
|
onChange={(e) => setTimeRange(e.target.value)}
|
||||||
className="bg-transparent text-white border-none outline-none"
|
className="bg-transparent text-white border-none outline-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="7d" className="text-gray-900">Last 7 days</option>
|
<option value="7d" className="text-gray-900 bg-white">Last 7 days</option>
|
||||||
<option value="30d" className="text-gray-900">Last 30 days</option>
|
<option value="30d" className="text-gray-900 bg-white">Last 30 days</option>
|
||||||
<option value="90d" className="text-gray-900">Last 90 days</option>
|
<option value="90d" className="text-gray-900 bg-white">Last 90 days</option>
|
||||||
<option value="1y" className="text-gray-900">Last year</option>
|
<option value="1y" className="text-gray-900 bg-white">Last year</option>
|
||||||
<option value="all" className="text-gray-900">All time</option>
|
<option value="all" className="text-gray-900 bg-white">All time</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
className="bg-white/10 backdrop-blur-sm hover:bg-white/20 transition-all duration-200 rounded-lg px-4 py-2 flex items-center space-x-2"
|
className="bg-slate-700/50 backdrop-blur-sm hover:bg-slate-600/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 rounded-lg px-4 py-2 flex items-center space-x-2 border border-slate-600"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 text-slate-300 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
<span>Refresh</span>
|
<span className="text-slate-300">Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button className="bg-white text-slate-700 hover:bg-slate-50 transition-all duration-200 rounded-lg px-4 py-2 font-medium flex items-center space-x-2 text-sm">
|
<button className="bg-slate-600 text-white hover:bg-slate-500 transition-all duration-200 rounded-lg px-4 py-2 font-medium flex items-center space-x-2 text-sm border border-slate-500">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span>Quick Actions</span>
|
<span>Quick Actions</span>
|
||||||
</button>
|
</button>
|
||||||
@ -336,257 +389,482 @@ const Dashboard = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Row */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* Transaction Volume Chart */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
|
{/* Sample Data Analytics Charts */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="space-y-8">
|
||||||
<div>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
Market Activity
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<BarChart3 className="h-5 w-5 mr-2 text-blue-500" />
|
||||||
|
Transaction Volume by Area
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex space-x-2">
|
||||||
{timeRange === '7d' ? 'Last 7 days' :
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
timeRange === '30d' ? 'Last 30 days' :
|
<RefreshCw className="h-4 w-4" />
|
||||||
timeRange === '90d' ? 'Last 90 days' :
|
</button>
|
||||||
timeRange === '1y' ? 'Last year' :
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
timeRange === 'all' ? 'All time' : 'Last 90 days'} transaction trends
|
<Download className="h-4 w-4" />
|
||||||
</p>
|
</button>
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleGenerateReport('transaction_summary')}
|
|
||||||
className="p-2 text-gray-400 hover:text-slate-600 dark:hover:text-slate-400 transition-colors duration-200"
|
|
||||||
title="Generate Report"
|
|
||||||
>
|
|
||||||
<Download className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<div className="w-10 h-10 bg-slate-100 dark:bg-slate-900/20 rounded-lg flex items-center justify-center">
|
|
||||||
<BarChart3 className="h-6 w-6 text-slate-600 dark:text-slate-400" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{areaStatsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
) : areaStats && areaStats.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={areaStats}
|
||||||
|
type="bar"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<BarChart3 className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No area data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<TrendingUp className="h-5 w-5 mr-2 text-green-500" />
|
||||||
|
Transaction Trends
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{timeSeriesLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
|
||||||
|
</div>
|
||||||
|
) : timeSeriesData && timeSeriesData.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={timeSeriesData}
|
||||||
|
type="line"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No trend data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Chart
|
|
||||||
data={processedTimeSeriesData}
|
|
||||||
type="line"
|
|
||||||
height={350}
|
|
||||||
title="Market Activity"
|
|
||||||
subtitle="No transaction data available for the selected period"
|
|
||||||
options={{
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: true,
|
|
||||||
position: 'top',
|
|
||||||
labels: {
|
|
||||||
usePointStyle: true,
|
|
||||||
padding: 20,
|
|
||||||
font: {
|
|
||||||
size: 12,
|
|
||||||
weight: '500'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
mode: 'index',
|
|
||||||
intersect: false,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
titleColor: 'white',
|
|
||||||
bodyColor: 'white',
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
borderWidth: 1,
|
|
||||||
cornerRadius: 8,
|
|
||||||
displayColors: true,
|
|
||||||
callbacks: {
|
|
||||||
label: function(context) {
|
|
||||||
if (context.datasetIndex === 1) {
|
|
||||||
return `${context.dataset.label}: AED ${context.parsed.y.toFixed(1)}M`
|
|
||||||
}
|
|
||||||
return `${context.dataset.label}: ${context.parsed.y}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
type: 'linear',
|
|
||||||
display: true,
|
|
||||||
position: 'left',
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(0, 0, 0, 0.05)',
|
|
||||||
drawBorder: false
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
callback: function(value) {
|
|
||||||
return value.toLocaleString()
|
|
||||||
},
|
|
||||||
font: {
|
|
||||||
size: 11
|
|
||||||
},
|
|
||||||
color: '#6B7280'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
y1: {
|
|
||||||
type: 'linear',
|
|
||||||
display: true,
|
|
||||||
position: 'right',
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: {
|
|
||||||
drawOnChartArea: false,
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
callback: function(value) {
|
|
||||||
return `AED ${value.toFixed(1)}M`
|
|
||||||
},
|
|
||||||
font: {
|
|
||||||
size: 11
|
|
||||||
},
|
|
||||||
color: '#6B7280'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(0, 0, 0, 0.05)',
|
|
||||||
drawBorder: false
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
font: {
|
|
||||||
size: 11
|
|
||||||
},
|
|
||||||
color: '#6B7280'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
interaction: {
|
|
||||||
mode: 'nearest',
|
|
||||||
axis: 'x',
|
|
||||||
intersect: false
|
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
line: {
|
|
||||||
tension: 0.4,
|
|
||||||
borderWidth: 3
|
|
||||||
},
|
|
||||||
point: {
|
|
||||||
radius: 6,
|
|
||||||
hoverRadius: 8,
|
|
||||||
borderWidth: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Area Distribution Chart */}
|
{/* Brokers Analysis */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
<div>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
Top Areas
|
<Users className="h-5 w-5 mr-2 text-purple-500" />
|
||||||
|
Broker Gender Distribution
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<div className="flex space-x-2">
|
||||||
Property distribution by area
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
</p>
|
<RefreshCw className="h-4 w-4" />
|
||||||
</div>
|
</button>
|
||||||
<div className="flex items-center space-x-3">
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
<button
|
<Download className="h-4 w-4" />
|
||||||
onClick={() => handleGenerateReport('area_analysis')}
|
</button>
|
||||||
className="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 transition-colors duration-200"
|
|
||||||
title="Generate Report"
|
|
||||||
>
|
|
||||||
<Download className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
|
|
||||||
<PieChart className="h-6 w-6 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{brokerStatsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
|
||||||
|
</div>
|
||||||
|
) : brokerStatsError ? (
|
||||||
|
<div className="flex items-center justify-center h-64 text-red-500">
|
||||||
|
<div className="text-center">
|
||||||
|
<p>Error: {brokerStatsError.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : brokerStats && brokerStats.gender_distribution && brokerStats.gender_distribution.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={brokerStats.gender_distribution.map(item => ({
|
||||||
|
area: item.gender,
|
||||||
|
transaction_count: item.count
|
||||||
|
}))}
|
||||||
|
type="pie"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<Users className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No broker data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<Building2 className="h-5 w-5 mr-2 text-orange-500" />
|
||||||
|
Top Real Estate Companies
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{brokerStatsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"></div>
|
||||||
|
</div>
|
||||||
|
) : brokerStats && brokerStats.top_companies && brokerStats.top_companies.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={brokerStats.top_companies.map(item => ({
|
||||||
|
area: item.real_estate_name_en,
|
||||||
|
transaction_count: item.broker_count
|
||||||
|
}))}
|
||||||
|
type="bar"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<Building2 className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No company data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects Analysis */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<Activity className="h-5 w-5 mr-2 text-red-500" />
|
||||||
|
Project Status Distribution
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{projectStatsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-red-500"></div>
|
||||||
|
</div>
|
||||||
|
) : projectStats && projectStats.status_distribution && projectStats.status_distribution.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={projectStats.status_distribution.map(item => ({
|
||||||
|
area: item.project_status,
|
||||||
|
transaction_count: item.count
|
||||||
|
}))}
|
||||||
|
type="pie"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<Activity className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No project data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<TrendingUp className="h-5 w-5 mr-2 text-indigo-500" />
|
||||||
|
Project Completion Rates
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{projectStatsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500"></div>
|
||||||
|
</div>
|
||||||
|
) : projectStats && projectStats.completion_rates && projectStats.completion_rates.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={projectStats.completion_rates.map(item => ({
|
||||||
|
area: item.range,
|
||||||
|
transaction_count: item.count
|
||||||
|
}))}
|
||||||
|
type="bar"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No completion data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lands Analysis */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<MapPin className="h-5 w-5 mr-2 text-green-500" />
|
||||||
|
Land Type Distribution
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{landStatsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500"></div>
|
||||||
|
</div>
|
||||||
|
) : landStats && landStats.type_distribution && landStats.type_distribution.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={landStats.type_distribution.map(item => ({
|
||||||
|
area: item.land_type,
|
||||||
|
transaction_count: item.count
|
||||||
|
}))}
|
||||||
|
type="pie"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<MapPin className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No land data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<Building2 className="h-5 w-5 mr-2 text-blue-500" />
|
||||||
|
Top Areas by Land Count
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{landStatsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
) : landStats && landStats.area_distribution && landStats.area_distribution.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={landStats.area_distribution.map(item => ({
|
||||||
|
area: item.area_en,
|
||||||
|
transaction_count: item.land_count
|
||||||
|
}))}
|
||||||
|
type="bar"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<Building2 className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No area data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Valuations Analysis */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<DollarSign className="h-5 w-5 mr-2 text-yellow-500" />
|
||||||
|
Property Value Trends
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{valuationStatsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-yellow-500"></div>
|
||||||
|
</div>
|
||||||
|
) : valuationStats && valuationStats.value_trends && valuationStats.value_trends.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={valuationStats.value_trends.map(item => ({
|
||||||
|
date: item.month,
|
||||||
|
value: item.avg_value
|
||||||
|
}))}
|
||||||
|
type="line"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<DollarSign className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No valuation data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<MapPin className="h-5 w-5 mr-2 text-purple-500" />
|
||||||
|
Top Valued Areas
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{valuationStatsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
|
||||||
|
</div>
|
||||||
|
) : valuationStats && valuationStats.top_valued_areas && valuationStats.top_valued_areas.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={valuationStats.top_valued_areas.map(item => ({
|
||||||
|
area: item.area_en,
|
||||||
|
transaction_count: Math.round(item.avg_value / 1000000) // Convert to millions for display
|
||||||
|
}))}
|
||||||
|
type="bar"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<MapPin className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No area valuation data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rents Analysis */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<Home className="h-5 w-5 mr-2 text-cyan-500" />
|
||||||
|
Rental Property Types
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{rentStatsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyan-500"></div>
|
||||||
|
</div>
|
||||||
|
) : rentStats && rentStats.property_types && rentStats.property_types.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={rentStats.property_types.map(item => ({
|
||||||
|
area: item.property_type,
|
||||||
|
transaction_count: item.count
|
||||||
|
}))}
|
||||||
|
type="pie"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<Home className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No rental data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<TrendingUp className="h-5 w-5 mr-2 text-pink-500" />
|
||||||
|
Average Rental Prices by Area
|
||||||
|
</h3>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{rentStatsLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div>
|
||||||
|
</div>
|
||||||
|
) : rentStats && rentStats.area_prices && rentStats.area_prices.length > 0 ? (
|
||||||
|
<Chart
|
||||||
|
data={rentStats.area_prices.map(item => ({
|
||||||
|
area: item.area_en,
|
||||||
|
transaction_count: Math.round(item.avg_rent / 1000) // Convert to thousands for display
|
||||||
|
}))}
|
||||||
|
type="bar"
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<TrendingUp className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No rental price data available</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Chart
|
|
||||||
data={processedAreaStatsData}
|
|
||||||
type="bar"
|
|
||||||
height={350}
|
|
||||||
title="Area Distribution"
|
|
||||||
subtitle="No area data available for the selected period"
|
|
||||||
options={{
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: true,
|
|
||||||
position: 'top',
|
|
||||||
labels: {
|
|
||||||
usePointStyle: true,
|
|
||||||
padding: 20,
|
|
||||||
font: {
|
|
||||||
size: 12,
|
|
||||||
weight: '500'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
mode: 'index',
|
|
||||||
intersect: false,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
titleColor: 'white',
|
|
||||||
bodyColor: 'white',
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
borderWidth: 1,
|
|
||||||
cornerRadius: 8,
|
|
||||||
displayColors: true,
|
|
||||||
callbacks: {
|
|
||||||
label: function(context) {
|
|
||||||
return `${context.dataset.label}: ${context.parsed.y} transactions`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(0, 0, 0, 0.05)',
|
|
||||||
drawBorder: false
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
callback: function(value) {
|
|
||||||
return value.toLocaleString()
|
|
||||||
},
|
|
||||||
font: {
|
|
||||||
size: 11
|
|
||||||
},
|
|
||||||
color: '#6B7280'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(0, 0, 0, 0.05)',
|
|
||||||
drawBorder: false
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
font: {
|
|
||||||
size: 11
|
|
||||||
},
|
|
||||||
color: '#6B7280',
|
|
||||||
maxRotation: 45,
|
|
||||||
minRotation: 45
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
interaction: {
|
|
||||||
mode: 'nearest',
|
|
||||||
axis: 'x',
|
|
||||||
intersect: false
|
|
||||||
},
|
|
||||||
elements: {
|
|
||||||
bar: {
|
|
||||||
borderRadius: 6,
|
|
||||||
borderSkipped: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -600,7 +878,7 @@ const Dashboard = () => {
|
|||||||
{/* Quick Actions & System Status */}
|
{/* Quick Actions & System Status */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-6">
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-6">
|
||||||
Quick Actions
|
Quick Actions
|
||||||
</h3>
|
</h3>
|
||||||
@ -628,7 +906,7 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* System Status */}
|
{/* System Status */}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
|
||||||
System Status
|
System Status
|
||||||
</h3>
|
</h3>
|
||||||
@ -661,7 +939,7 @@ const Dashboard = () => {
|
|||||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-white">Data Records</span>
|
<span className="text-sm font-medium text-gray-900 dark:text-white">Data Records</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold text-purple-600 dark:text-purple-400">36,457+</span>
|
<span className="text-sm font-semibold text-purple-600 dark:text-purple-400">500,000+</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -322,7 +322,7 @@ const Payments = () => {
|
|||||||
const paymentStatsData = [
|
const paymentStatsData = [
|
||||||
{
|
{
|
||||||
title: 'Total Revenue',
|
title: 'Total Revenue',
|
||||||
value: `AED ${paymentStats?.total_value?.toLocaleString() || '0'}`,
|
value: `AED ${typeof paymentStats?.total_value === 'number' ? paymentStats.total_value.toLocaleString() : '0'}`,
|
||||||
change: '+12.5%',
|
change: '+12.5%',
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
@ -333,7 +333,7 @@ const Payments = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total Transactions',
|
title: 'Total Transactions',
|
||||||
value: paymentStats?.total_transactions?.toLocaleString() || '0',
|
value: typeof paymentStats?.total_transactions === 'number' ? paymentStats.total_transactions.toLocaleString() : '0',
|
||||||
change: '+8.2%',
|
change: '+8.2%',
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
@ -344,7 +344,7 @@ const Payments = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Average Transaction',
|
title: 'Average Transaction',
|
||||||
value: `AED ${paymentStats?.average_value?.toLocaleString() || '0'}`,
|
value: `AED ${typeof paymentStats?.average_value === 'number' ? paymentStats.average_value.toLocaleString() : '0'}`,
|
||||||
change: '+3.1%',
|
change: '+3.1%',
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
@ -355,7 +355,7 @@ const Payments = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Active Users',
|
title: 'Active Users',
|
||||||
value: displayUsers?.length?.toLocaleString() || '0',
|
value: typeof displayUsers?.length === 'number' ? displayUsers.length.toLocaleString() : '0',
|
||||||
change: '+15.3%',
|
change: '+15.3%',
|
||||||
changeType: 'positive',
|
changeType: 'positive',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
@ -764,7 +764,7 @@ const Payments = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-sm font-semibold text-gray-900 dark:text-white">
|
<td className="py-3 px-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
AED {transaction.transaction_value?.toLocaleString() || '0'}
|
AED {typeof transaction.transaction_value === 'number' ? transaction.transaction_value.toLocaleString() : '0'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-300">
|
<td className="py-3 px-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
{transaction.property_type || 'N/A'} - {transaction.area_en || 'Unknown Area'}
|
{transaction.property_type || 'N/A'} - {transaction.area_en || 'Unknown Area'}
|
||||||
|
|||||||
@ -160,7 +160,7 @@ const Reports = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowGenerateModal(true)}
|
onClick={() => setShowGenerateModal(true)}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary btn-md"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Generate Report
|
Generate Report
|
||||||
@ -333,13 +333,13 @@ const Reports = () => {
|
|||||||
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateReport}
|
onClick={handleGenerateReport}
|
||||||
className="btn btn-primary btn-sm mr-2"
|
className="btn btn-primary btn-md mr-3"
|
||||||
>
|
>
|
||||||
Generate
|
Generate Report
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowGenerateModal(false)}
|
onClick={() => setShowGenerateModal(false)}
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-md"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -27,7 +27,7 @@ api.interceptors.request.use(
|
|||||||
|
|
||||||
// Response interceptor to handle token refresh
|
// Response interceptor to handle token refresh
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response.data,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config
|
const originalRequest = error.config
|
||||||
|
|
||||||
@ -97,12 +97,19 @@ export const authAPI = {
|
|||||||
|
|
||||||
export const analyticsAPI = {
|
export const analyticsAPI = {
|
||||||
getTransactions: (params) => api.get('/analytics/transactions/', { params }),
|
getTransactions: (params) => api.get('/analytics/transactions/', { params }),
|
||||||
getTransactionSummary: (params) => api.get('/analytics/summary/', { params }),
|
getTransactionSummary: (params) => api.get('/analytics/transaction-summary/', { params }),
|
||||||
getAreaStats: (params) => api.get('/analytics/area-stats-data/', { params }),
|
getAreaStats: (params) => api.get('/analytics/area-statistics/', { params }),
|
||||||
getPropertyTypeStats: (params) => api.get('/analytics/property-type-stats/', { params }),
|
getPropertyTypeStats: (params) => api.get('/analytics/property-type-statistics/', { params }),
|
||||||
getTimeSeriesData: (params) => api.get('/analytics/time-series-data/', { params }),
|
getTimeSeriesData: (params) => api.get('/analytics/time-series-data/', { params }),
|
||||||
getMarketAnalysis: (params) => api.get('/analytics/market-analysis/', { params }),
|
getMarketAnalysis: (params) => api.get('/analytics/market-analysis/', { params }),
|
||||||
generateForecast: (data) => api.post('/analytics/forecast/', data),
|
generateForecast: (data) => api.post('/analytics/generate-forecast/', data),
|
||||||
|
|
||||||
|
// Additional endpoints for sample data analytics
|
||||||
|
getBrokerStats: () => api.get('/analytics/broker-stats/'),
|
||||||
|
getProjectStats: () => api.get('/analytics/project-stats/'),
|
||||||
|
getLandStats: () => api.get('/analytics/land-stats/'),
|
||||||
|
getValuationStats: () => api.get('/analytics/valuation-stats/'),
|
||||||
|
getRentStats: () => api.get('/analytics/rent-stats/'),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reportsAPI = {
|
export const reportsAPI = {
|
||||||
|
|||||||
38
install-visualization-deps.sh
Executable file
38
install-visualization-deps.sh
Executable file
@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Installation script for enhanced visualization dependencies
|
||||||
|
# Run this script from the frontend directory
|
||||||
|
|
||||||
|
echo "🚀 Installing enhanced visualization dependencies..."
|
||||||
|
|
||||||
|
# Navigate to frontend directory
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install new dependencies
|
||||||
|
echo "📦 Installing Leaflet and mapping libraries..."
|
||||||
|
npm install leaflet@^1.9.4 react-leaflet@^4.2.1 react-leaflet-cluster@^2.0.0
|
||||||
|
|
||||||
|
echo "📊 Installing advanced charting libraries..."
|
||||||
|
npm install plotly.js@^2.27.0 react-plotly.js@^2.6.0
|
||||||
|
|
||||||
|
echo "📤 Installing export and sharing libraries..."
|
||||||
|
npm install jspdf@^2.5.1 html2canvas@^1.4.1 file-saver@^2.0.5 react-csv@^2.2.2
|
||||||
|
|
||||||
|
echo "🎨 Installing UI enhancement libraries..."
|
||||||
|
npm install framer-motion@^10.16.16 react-intersection-observer@^9.5.3
|
||||||
|
|
||||||
|
echo "📱 Installing layout and grid libraries..."
|
||||||
|
npm install react-grid-layout@^1.4.4 react-resizable@^3.0.5
|
||||||
|
|
||||||
|
echo "✅ All dependencies installed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "🎯 Next steps:"
|
||||||
|
echo "1. Start the development server: npm run dev"
|
||||||
|
echo "2. Navigate to the Analytics page to see the new visualizations"
|
||||||
|
echo "3. Check out the new features:"
|
||||||
|
echo " - Geographic Heat Maps"
|
||||||
|
echo " - Advanced Time Series Charts"
|
||||||
|
echo " - Comparative Analysis"
|
||||||
|
echo " - Export & Sharing capabilities"
|
||||||
|
echo ""
|
||||||
|
echo "📚 For more information, see VISUALIZATION_FEATURES.md"
|
||||||
208
setup_api_user.sh
Executable file
208
setup_api_user.sh
Executable file
@ -0,0 +1,208 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Dubai Analytics Platform - API User Setup Script
|
||||||
|
# This script helps you register a new user and get your API key
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BASE_URL="http://localhost:8000/api/v1"
|
||||||
|
|
||||||
|
echo "🏢 Dubai Analytics Platform - API Setup"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if curl is installed
|
||||||
|
if ! command -v curl &> /dev/null; then
|
||||||
|
echo "❌ Error: curl is not installed. Please install curl first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if jq is installed
|
||||||
|
if ! command -v jq &> /dev/null; then
|
||||||
|
echo "⚠️ Warning: jq is not installed. JSON responses will not be formatted."
|
||||||
|
echo " Install jq for better output formatting: sudo apt-get install jq"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to format JSON output
|
||||||
|
format_json() {
|
||||||
|
if command -v jq &> /dev/null; then
|
||||||
|
jq '.'
|
||||||
|
else
|
||||||
|
cat
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to register a new user
|
||||||
|
register_user() {
|
||||||
|
echo "📝 User Registration"
|
||||||
|
echo "-------------------"
|
||||||
|
|
||||||
|
read -p "Enter username: " username
|
||||||
|
read -p "Enter email: " email
|
||||||
|
read -s -p "Enter password: " password
|
||||||
|
echo ""
|
||||||
|
read -p "Enter first name: " first_name
|
||||||
|
read -p "Enter last name: " last_name
|
||||||
|
read -p "Enter company name: " company_name
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔄 Registering user..."
|
||||||
|
|
||||||
|
response=$(curl -s -X POST "$BASE_URL/auth/register/" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"username\": \"$username\",
|
||||||
|
\"email\": \"$email\",
|
||||||
|
\"password\": \"$password\",
|
||||||
|
\"first_name\": \"$first_name\",
|
||||||
|
\"last_name\": \"$last_name\",
|
||||||
|
\"company_name\": \"$company_name\"
|
||||||
|
}")
|
||||||
|
|
||||||
|
if echo "$response" | format_json | grep -q "User created successfully"; then
|
||||||
|
echo "✅ User registered successfully!"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Extract API key from response
|
||||||
|
api_key=$(echo "$response" | grep -o '"api_key":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -n "$api_key" ]; then
|
||||||
|
echo "🔑 Your API Key: $api_key"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Save this API key securely! You'll need it for all API requests."
|
||||||
|
echo ""
|
||||||
|
echo "🧪 Test your API key:"
|
||||||
|
echo "curl -X GET \"$BASE_URL/analytics/broker-stats/\" \\"
|
||||||
|
echo " -H \"X-API-Key: $api_key\""
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "❌ Error: Could not extract API key from response"
|
||||||
|
echo "Response: $response"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract JWT token for additional testing
|
||||||
|
access_token=$(echo "$response" | grep -o '"access":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -n "$access_token" ]; then
|
||||||
|
echo "🔐 Your JWT Token (for web interface): $access_token"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
echo "❌ Registration failed!"
|
||||||
|
echo "Response: $response" | format_json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to test API key
|
||||||
|
test_api_key() {
|
||||||
|
echo "🧪 API Key Testing"
|
||||||
|
echo "-----------------"
|
||||||
|
|
||||||
|
read -p "Enter your API key: " api_key
|
||||||
|
|
||||||
|
if [ -z "$api_key" ]; then
|
||||||
|
echo "❌ API key cannot be empty"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔄 Testing API key with broker statistics endpoint..."
|
||||||
|
|
||||||
|
response=$(curl -s -X GET "$BASE_URL/analytics/broker-stats/" \
|
||||||
|
-H "X-API-Key: $api_key")
|
||||||
|
|
||||||
|
if echo "$response" | grep -q "gender_distribution"; then
|
||||||
|
echo "✅ API key is working correctly!"
|
||||||
|
echo ""
|
||||||
|
echo "📊 Sample response:"
|
||||||
|
echo "$response" | format_json
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "❌ API key test failed!"
|
||||||
|
echo "Response: $response" | format_json
|
||||||
|
echo ""
|
||||||
|
echo "Common issues:"
|
||||||
|
echo "- Make sure the API key is correct"
|
||||||
|
echo "- Make sure the server is running on $BASE_URL"
|
||||||
|
echo "- Check if your account has API access enabled"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to show API examples
|
||||||
|
show_examples() {
|
||||||
|
echo "📚 API Usage Examples"
|
||||||
|
echo "-------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Enter your API key: " api_key
|
||||||
|
|
||||||
|
if [ -z "$api_key" ]; then
|
||||||
|
echo "❌ API key cannot be empty"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔗 Here are some example API calls you can make:"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "1. Get Broker Statistics:"
|
||||||
|
echo "curl -X GET \"$BASE_URL/analytics/broker-stats/\" \\"
|
||||||
|
echo " -H \"X-API-Key: $api_key\""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "2. Get Project Statistics:"
|
||||||
|
echo "curl -X GET \"$BASE_URL/analytics/project-stats/\" \\"
|
||||||
|
echo " -H \"X-API-Key: $api_key\""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "3. Get Time Series Data (2025):"
|
||||||
|
echo "curl -X GET \"$BASE_URL/analytics/time-series-data/?start_date=2025-01-01&end_date=2025-12-31&group_by=month\" \\"
|
||||||
|
echo " -H \"X-API-Key: $api_key\""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "4. Get Transaction Summary:"
|
||||||
|
echo "curl -X GET \"$BASE_URL/analytics/transaction-summary/?start_date=2025-01-01&end_date=2025-12-31\" \\"
|
||||||
|
echo " -H \"X-API-Key: $api_key\""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "5. Get Area Statistics:"
|
||||||
|
echo "curl -X GET \"$BASE_URL/analytics/area-statistics/?start_date=2025-01-01&end_date=2025-12-31&limit=10\" \\"
|
||||||
|
echo " -H \"X-API-Key: $api_key\""
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main menu
|
||||||
|
while true; do
|
||||||
|
echo "What would you like to do?"
|
||||||
|
echo "1. Register a new user and get API key"
|
||||||
|
echo "2. Test existing API key"
|
||||||
|
echo "3. Show API usage examples"
|
||||||
|
echo "4. Exit"
|
||||||
|
echo ""
|
||||||
|
read -p "Enter your choice (1-4): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
register_user
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
test_api_key
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
show_examples
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
echo "👋 Goodbye!"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Invalid choice. Please enter 1, 2, 3, or 4."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Press Enter to continue..."
|
||||||
|
read
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
Loading…
Reference in New Issue
Block a user