bars,charts,maps,analysis,apis

This commit is contained in:
rohit 2025-09-18 13:25:46 +05:30
parent d38f804983
commit 493778f955
24 changed files with 7743 additions and 415 deletions

518
API_DOCUMENTATION.md Normal file
View 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
View 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

View File

@ -11,15 +11,22 @@ urlpatterns = [
path('brokers/', views.BrokerListView.as_view(), name='broker_list'),
# Analytics endpoints
path('summary/', views.transaction_summary, name='transaction_summary'),
path('area-stats/', views.area_statistics, name='area_statistics'),
path('property-type-stats/', views.property_type_statistics, name='property_type_statistics'),
path('transaction-summary/', views.transaction_summary, name='transaction_summary'),
path('area-statistics/', views.area_statistics, name='area_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-data/', views.get_time_series_data, name='get_time_series_data'),
path('area-stats-data/', views.get_area_stats, name='get_area_stats'),
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
path('forecast/', views.generate_forecast, name='generate_forecast'),
path('generate-forecast/', views.generate_forecast, name='generate_forecast'),
]

View File

@ -3,7 +3,7 @@ Views for analytics and data analysis.
"""
from rest_framework import generics, status
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 django.db.models import Avg, Count, Min, Max, Sum, Q
from django.utils import timezone
@ -514,3 +514,148 @@ def get_area_stats(request):
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
)

View File

@ -8,6 +8,7 @@ urlpatterns = [
# Authentication
path('register/', views.UserRegistrationView.as_view(), name='user_register'),
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('user/', views.get_current_user, name='get_current_user'),

View File

@ -33,12 +33,18 @@ class UserRegistrationView(generics.CreateAPIView):
# Create user profile
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
refresh = RefreshToken.for_user(user)
return Response({
'message': 'User created successfully',
'user': UserSerializer(user).data,
'api_key': user.api_key,
'tokens': {
'refresh': str(refresh),
'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'])
@permission_classes([IsAuthenticated])
def logout_view(request):

File diff suppressed because it is too large Load Diff

View File

@ -15,18 +15,31 @@
"axios": "^1.6.2",
"chart.js": "^4.4.0",
"chartjs-adapter-date-fns": "^3.0.0",
"clsx": "^2.0.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",
"plotly.js": "^2.35.3",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-csv": "^2.2.2",
"react-dom": "^18.2.0",
"react-grid-layout": "^1.5.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-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",
"tailwind-merge": "^2.0.0",
"clsx": "^2.0.0"
"tailwind-merge": "^2.0.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
@ -42,4 +55,3 @@
"vite": "^4.5.0"
}
}

View File

@ -13,6 +13,16 @@ import Settings from './pages/Settings'
import Payments from './pages/Payments'
import { selectIsAuthenticated } from './store/slices/authSlice'
function App() {
return (
<ThemeProvider>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</ThemeProvider>
)
}
function AppRoutes() {
const { isAuthenticated, isInitialized } = useAuthState()
const { isLoading } = useAuth()
@ -45,14 +55,4 @@ function AppRoutes() {
)
}
function App() {
return (
<ThemeProvider>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</ThemeProvider>
)
}
export default App

View File

@ -56,6 +56,17 @@ const Chart = ({ data, type, height = 300, options = {}, title, subtitle }) => {
const defaultOptions = {
responsive: true,
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: {
legend: {
position: 'top',

View 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

View 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

View 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='&copy; <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

View 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;
}

View 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

View File

@ -33,9 +33,9 @@ export const AuthProvider = ({ children }) => {
try {
// Verify token with backend
const response = await api.get('/auth/user/')
if (response.data) {
if (response) {
dispatch(setCredentials({
user: response.data,
user: response,
token
}))
} else {
@ -63,13 +63,13 @@ export const AuthProvider = ({ children }) => {
if (refreshToken) {
try {
const refreshResponse = await api.post('/auth/refresh/', { refresh: refreshToken })
const newToken = refreshResponse.data.access
const newToken = refreshResponse.access
localStorage.setItem('accessToken', newToken)
// Get user data with new token
const userResponse = await api.get('/auth/user/')
dispatch(setCredentials({
user: userResponse.data,
user: userResponse,
token: newToken
}))
} catch (refreshError) {
@ -108,7 +108,7 @@ export const AuthProvider = ({ children }) => {
const login = async (email, password) => {
try {
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('refreshToken', tokens.refresh)
@ -119,7 +119,7 @@ export const AuthProvider = ({ children }) => {
} catch (error) {
return {
success: false,
error: error.response?.data?.message || 'Login failed'
error: error.response?.data?.message || error.message || 'Login failed'
}
}
}

View File

@ -2,6 +2,37 @@
@tailwind components;
@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 {
--toast-bg: #ffffff;
--toast-color: #1f2937;
@ -25,15 +56,15 @@
@layer components {
.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 {
@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 {
@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 {
@ -41,15 +72,15 @@
}
.btn-sm {
@apply h-8 px-3 text-xs;
@apply h-9 px-4 text-xs;
}
.btn-md {
@apply h-10 px-4 py-2;
@apply h-11 px-6 py-2.5 text-sm;
}
.btn-lg {
@apply h-12 px-8 text-base;
@apply h-12 px-8 text-base py-3;
}
.card {

View File

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart3,
@ -7,11 +7,24 @@ import {
Building2,
Filter,
Download,
RefreshCw
RefreshCw,
Globe,
BarChart,
PieChart,
Activity,
Share2,
Settings,
Maximize2,
Calendar,
Layers
} from 'lucide-react'
import { analyticsAPI } from '../services/api'
import Chart from '../components/Chart'
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 [filters, setFilters] = useState({
@ -21,6 +34,15 @@ const Analytics = () => {
end_date: '',
})
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({
queryKey: ['transactionSummary', filters],
@ -70,15 +92,25 @@ const Analytics = () => {
const tabs = [
{ 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: '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 ? [
{
title: 'Total Transactions',
value: summary.total_transactions?.toLocaleString() || '0',
value: typeof summary.total_transactions === 'number' ? summary.total_transactions.toLocaleString() : '0',
change: '+12%',
changeType: 'positive',
icon: BarChart3,
@ -86,7 +118,7 @@ const Analytics = () => {
},
{
title: 'Total Value',
value: `AED ${summary.total_value?.toLocaleString() || '0'}`,
value: `AED ${typeof summary.total_value === 'number' ? summary.total_value.toLocaleString() : '0'}`,
change: '+8%',
changeType: 'positive',
icon: TrendingUp,
@ -94,7 +126,7 @@ const Analytics = () => {
},
{
title: 'Average Price',
value: `AED ${summary.average_price?.toLocaleString() || '0'}`,
value: `AED ${typeof summary.average_price === 'number' ? summary.average_price.toLocaleString() : '0'}`,
change: '+5%',
changeType: 'positive',
icon: Building2,
@ -102,7 +134,7 @@ const Analytics = () => {
},
{
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%',
changeType: 'positive',
icon: MapPin,
@ -113,24 +145,44 @@ const Analytics = () => {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
Analytics
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400">
Real estate market insights and trends
</p>
</div>
<div className="flex items-center space-x-2">
<button className="btn btn-secondary">
<Download className="h-4 w-4 mr-2" />
Export
</button>
<button className="btn btn-primary">
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</button>
<div className="bg-gradient-to-r from-slate-800 via-slate-700 to-slate-600 rounded-2xl p-6 text-white shadow-xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold mb-2 tracking-tight">
Advanced Analytics
</h1>
<p className="text-slate-200 text-base font-normal">
Comprehensive real estate market insights with interactive visualizations
</p>
<div className="flex items-center mt-4 space-x-6">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-sm font-medium">Live Data</span>
</div>
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-slate-300" />
<span className="text-sm font-medium">Last updated: {new Date().toLocaleTimeString()}</span>
</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>
@ -180,13 +232,13 @@ const Analytics = () => {
/>
</div>
</div>
<div className="flex items-center space-x-2 mt-4">
<button onClick={handleApplyFilters} className="btn btn-primary">
<div className="flex items-center space-x-3 mt-6">
<button onClick={handleApplyFilters} className="btn btn-primary btn-md">
<Filter className="h-4 w-4 mr-2" />
Apply Filters
</button>
<button onClick={handleResetFilters} className="btn btn-secondary">
Reset
<button onClick={handleResetFilters} className="btn btn-secondary btn-md">
Reset Filters
</button>
</div>
</div>
@ -221,29 +273,121 @@ const Analytics = () => {
))}
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card p-6">
<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">
<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}
/>
{/* Visualization Type Selector */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Visualizations
</h3>
<div className="flex items-center space-x-2">
{visualizationTypes.map(type => (
<button
key={type.id}
onClick={() => setSelectedVisualization(type.id)}
className={`flex items-center space-x-2 px-3 py-2 rounded-lg border transition-all ${
selectedVisualization === type.id
? '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.name}</span>
</button>
))}
</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>
)}
@ -269,8 +413,8 @@ const Analytics = () => {
<tr key={index} className="table-row">
<td className="table-cell font-medium">{area.area}</td>
<td className="table-cell">{area.transaction_count}</td>
<td className="table-cell">AED {area.average_price?.toLocaleString()}</td>
<td className="table-cell">AED {area.average_price_per_sqft?.toFixed(2)}</td>
<td className="table-cell">AED {typeof area.average_price === 'number' ? area.average_price.toLocaleString() : '0'}</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">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
area.price_trend === 'Rising'
@ -319,10 +463,10 @@ const Analytics = () => {
</div>
<div className="text-right">
<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 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>
</div>
</div>
@ -333,6 +477,28 @@ const Analytics = () => {
</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' && (
<div className="space-y-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>
<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>Average Price: AED {marketAnalysis.key_metrics?.average_price?.toLocaleString()}</p>
<p>Price Volatility: {marketAnalysis.key_metrics?.price_volatility?.toFixed(2)}</p>
<p>Average Price: AED {typeof marketAnalysis.key_metrics?.average_price === 'number' ? marketAnalysis.key_metrics.average_price.toLocaleString() : '0'}</p>
<p>Price Volatility: {typeof marketAnalysis.key_metrics?.price_volatility === 'number' ? marketAnalysis.key_metrics.price_volatility.toFixed(2) : '0'}</p>
</div>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">

View File

@ -142,6 +142,38 @@ const Dashboard = () => {
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) {
const now = new Date()
const start = new Date()
@ -226,37 +258,58 @@ const Dashboard = () => {
const stats = [
{
title: 'Total Brokers',
value: '36,457',
change: '+2.3%',
changeType: 'positive',
value: brokerStats ?
(() => {
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,
color: 'blue',
loading: metricsLoading,
trend: '+2.3%',
trendIcon: ArrowUpRight
},
{
title: 'Active Projects',
value: '10',
change: '+0%',
changeType: 'neutral',
icon: Building2,
color: 'green',
loading: metricsLoading,
loading: brokerStatsLoading,
trend: '0%',
trendIcon: TrendingUp
},
{
title: 'Total Users',
value: metrics?.total_users?.toLocaleString() || '2',
change: '+100%',
title: 'Active Projects',
value: projectStats ?
(() => {
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',
icon: Users,
color: 'purple',
loading: metricsLoading,
trend: '+100%',
icon: Building2,
color: 'green',
loading: projectStatsLoading,
trend: '0%',
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',
value: metrics?.system_health === 'healthy' ? '100%' : '95%',
@ -271,7 +324,7 @@ const Dashboard = () => {
]
return (
<div className="space-y-8">
<div className="space-y-8 animate-fade-in">
{/* 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="flex items-center justify-between">
@ -295,32 +348,32 @@ const Dashboard = () => {
</div>
<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">
<Calendar className="h-4 w-4" />
<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 text-slate-300" />
<select
value={timeRange}
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="30d" className="text-gray-900">Last 30 days</option>
<option value="90d" className="text-gray-900">Last 90 days</option>
<option value="1y" className="text-gray-900">Last year</option>
<option value="all" className="text-gray-900">All time</option>
<option value="7d" className="text-gray-900 bg-white">Last 7 days</option>
<option value="30d" className="text-gray-900 bg-white">Last 30 days</option>
<option value="90d" className="text-gray-900 bg-white">Last 90 days</option>
<option value="1y" className="text-gray-900 bg-white">Last year</option>
<option value="all" className="text-gray-900 bg-white">All time</option>
</select>
</div>
<button
onClick={handleRefresh}
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' : ''}`} />
<span>Refresh</span>
<RefreshCw className={`h-4 w-4 text-slate-300 ${refreshing ? 'animate-spin' : ''}`} />
<span className="text-slate-300">Refresh</span>
</button>
<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" />
<span>Quick Actions</span>
</button>
@ -336,257 +389,482 @@ const Dashboard = () => {
))}
</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">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
Market Activity
{/* Sample Data Analytics Charts */}
<div className="space-y-8">
<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">
<BarChart3 className="h-5 w-5 mr-2 text-blue-500" />
Transaction Volume by Area
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{timeRange === '7d' ? 'Last 7 days' :
timeRange === '30d' ? 'Last 30 days' :
timeRange === '90d' ? 'Last 90 days' :
timeRange === '1y' ? 'Last year' :
timeRange === 'all' ? 'All time' : 'Last 90 days'} transaction trends
</p>
</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 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>
{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>
<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>
{/* Area Distribution Chart */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
Top Areas
{/* Brokers 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">
<Users className="h-5 w-5 mr-2 text-purple-500" />
Broker Gender Distribution
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Property distribution by area
</p>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => handleGenerateReport('area_analysis')}
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 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-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>
<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>
@ -600,7 +878,7 @@ const Dashboard = () => {
{/* Quick Actions & System Status */}
<div className="space-y-6">
{/* 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">
Quick Actions
</h3>
@ -628,7 +906,7 @@ const Dashboard = () => {
</div>
{/* 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">
System Status
</h3>
@ -661,7 +939,7 @@ const Dashboard = () => {
<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>
</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>

View File

@ -322,7 +322,7 @@ const Payments = () => {
const paymentStatsData = [
{
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%',
changeType: 'positive',
icon: DollarSign,
@ -333,7 +333,7 @@ const Payments = () => {
},
{
title: 'Total Transactions',
value: paymentStats?.total_transactions?.toLocaleString() || '0',
value: typeof paymentStats?.total_transactions === 'number' ? paymentStats.total_transactions.toLocaleString() : '0',
change: '+8.2%',
changeType: 'positive',
icon: CreditCard,
@ -344,7 +344,7 @@ const Payments = () => {
},
{
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%',
changeType: 'positive',
icon: TrendingUp,
@ -355,7 +355,7 @@ const Payments = () => {
},
{
title: 'Active Users',
value: displayUsers?.length?.toLocaleString() || '0',
value: typeof displayUsers?.length === 'number' ? displayUsers.length.toLocaleString() : '0',
change: '+15.3%',
changeType: 'positive',
icon: Users,
@ -764,7 +764,7 @@ const Payments = () => {
</div>
</td>
<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 className="py-3 px-4 text-sm text-gray-600 dark:text-gray-300">
{transaction.property_type || 'N/A'} - {transaction.area_en || 'Unknown Area'}

View File

@ -160,7 +160,7 @@ const Reports = () => {
</div>
<button
onClick={() => setShowGenerateModal(true)}
className="btn btn-primary"
className="btn btn-primary btn-md"
>
<Plus className="h-4 w-4 mr-2" />
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">
<button
onClick={handleGenerateReport}
className="btn btn-primary btn-sm mr-2"
className="btn btn-primary btn-md mr-3"
>
Generate
Generate Report
</button>
<button
onClick={() => setShowGenerateModal(false)}
className="btn btn-secondary btn-sm"
className="btn btn-secondary btn-md"
>
Cancel
</button>

View File

@ -27,7 +27,7 @@ api.interceptors.request.use(
// Response interceptor to handle token refresh
api.interceptors.response.use(
(response) => response,
(response) => response.data,
async (error) => {
const originalRequest = error.config
@ -97,12 +97,19 @@ export const authAPI = {
export const analyticsAPI = {
getTransactions: (params) => api.get('/analytics/transactions/', { params }),
getTransactionSummary: (params) => api.get('/analytics/summary/', { params }),
getAreaStats: (params) => api.get('/analytics/area-stats-data/', { params }),
getPropertyTypeStats: (params) => api.get('/analytics/property-type-stats/', { params }),
getTransactionSummary: (params) => api.get('/analytics/transaction-summary/', { params }),
getAreaStats: (params) => api.get('/analytics/area-statistics/', { params }),
getPropertyTypeStats: (params) => api.get('/analytics/property-type-statistics/', { params }),
getTimeSeriesData: (params) => api.get('/analytics/time-series-data/', { 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 = {

38
install-visualization-deps.sh Executable file
View 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
View 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