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