property end point craeted

This commit is contained in:
laxman 2025-11-05 10:39:06 +05:30
parent bac20a0ffb
commit a931d61758
5 changed files with 2677 additions and 2 deletions

File diff suppressed because it is too large Load Diff

539
public/js/properties.js Normal file
View File

@ -0,0 +1,539 @@
// Properties page logic for unified rent/sale endpoint
const API_BASE = '/api';
// Combined area dropdown values (from both transactions and rents)
const areas = [
'abu hail', 'al athbah', 'al aweer first', 'al aweer second', 'al bada', 'al baraha',
'al barari', 'al barsha first', 'al barsha second', 'al barsha third', 'al barshaa south first',
'al barshaa south second', 'al barshaa south third', 'al buteen', 'al dhagaya', 'al eyas',
'al furjan', 'al garhoud', 'al goze first', 'al goze fourth', 'al goze industrial first',
'al goze industrial fourth', 'al goze industrial second', 'al goze industrial third', 'al goze third',
'al hamriya', 'al hebiah fifth', 'al hebiah first', 'al hebiah fourth', 'al hebiah sixth',
'al hebiah third', 'al hudaiba', 'al jadaf', 'al jafliya', 'al karama', 'al khabeesi',
'al khail heights', 'al khairan first', 'al khawaneej first', 'al khawaneej second', 'al kheeran',
'al kifaf', 'al lusaily', 'al mamzer', 'al manara', 'al mararr', 'al merkadh',
'al mizhar first', 'al mizhar fourth', 'al mizhar second', 'al mizhar third', 'al murqabat',
'al muteena', 'al nahda first', 'al nahda second', 'al qusais', 'al qusais industrial fifth',
'al qusais industrial first', 'al qusais industrial fourth', 'al qusais industrial third', 'al raffa',
'al ras', 'al rashidiya', 'al rega', 'al rowaiyah third', 'al saffa first', 'al saffa second',
'al safouh first', 'al satwa', 'al suq al kabeer', 'al thanyah fifth', 'al thanyah third',
'al ttay', 'al twar fifth', 'al twar first', 'al twar fourth', 'al twar second', 'al twar third',
'al waha', 'al waheda', 'al warqa first', 'al warqa fourth', 'al warqa second', 'al warqa third',
'al warsan second', 'al warsan third', 'al wasl', 'al yelayiss 1', 'al yelayiss 2', 'al yelayiss 5',
'al yufrah 1', 'arabian ranches i', 'arabian ranches ii', 'arabian ranches iii',
'arabian ranches polo club', 'arjan', 'barsha heights', 'bluewaters', 'bukadra', 'burj khalifa',
'business bay', 'business park', 'cherrywoods', 'city of arabia', 'city walk', 'damac hills',
'discovery gardens', 'dmcc-ez2', 'down town jabal ali', 'dubai creek harbour', 'dubai design district',
'dubai golf city', 'dubai harbour', 'dubai healthcare city - phase 1', 'dubai healthcare city - phase 2',
'dubai hills', 'dubai industrial city', 'dubai international airport', 'dubai investment park first',
'dubai investment park second', 'dubai land residence complex', 'dubai lifestyle city', 'dubai marina',
'dubai maritime city', 'dubai production city', 'dubai science park', 'dubai south', 'dubai sports city',
'dubai studio city', 'dubai water canal', 'dubai water front', 'emaar south', 'emirate living',
'eyal nasser', 'falcon city of wonders', 'ghadeer al tair', 'ghadeer barashy', 'grand hills dubai',
'grand views', 'hadaeq sheikh mohammed bin rashid', 'hessyan second', 'hor al anz', 'hor al anz east',
'horizon', 'international city ph 1', 'international city ph 2 & 3', 'island 2', 'jabal ali first',
'jabal ali industrial first', 'jabal ali industrial second', 'jabel ali hills', 'jaddaf waterfront',
'jumeira bay', 'jumeirah beach residence', 'jumeirah first', 'jumeirah golf', 'jumeirah heights',
'jumeirah islands', 'jumeirah lakes towers', 'jumeirah living', 'jumeirah park', 'jumeirah second',
'jumeirah third', 'jumeirah village circle', 'jumeirah village triangle', 'la mer', 'lehbab first',
'lehbab second', 'living legends', 'liwan', 'liwan 2', 'madinat al mataar', 'madinat dubai almelaheyah',
'madinat hind 3', 'madinat hind 4', 'madinat latifa', 'majan', 'mankhool', 'margham', 'marsa dubai',
'mbr district 1', 'mbr district 7', "me'aisem first", "me'aisem second", 'medyan race course villas',
'mena jabal ali', 'meydan avenue', 'meydan one', 'millennium', 'mina rashid', 'mira', 'mirdif',
'motor city', 'mudon', 'muhaisanah first', 'muhaisanah fourth', 'muhaisanah second', 'muhaisanah third',
'mushrif', 'nad al hamar', 'nad al sheba gardens', 'nad al shiba first', 'nad al shiba fourth',
'nad al shiba second', 'nad al shiba third', 'nad shamma', 'nadd hessa', 'naif', 'nazwah', 'oud metha',
'palm deira', 'palm jabal ali', 'palm jumeirah', 'palmarosa', 'pearl jumeira', 'polo townhouses igo',
'port saeed', 'ras al khor', 'ras al khor industrial first', 'ras al khor industrial second',
'ras al khor industrial third', 'rega al buteen', 'remraam', 'rukan', 'saih shuaib 1', 'saih shuaib 2',
'saih shuaib 3', 'saih shuaib 4', 'sama al jadaf', 'serena', 'silicon oasis', 'sobha heartland',
'sufouh gardens', 'sustainable city', 'tecom site a', 'tecom site d', 'the beach', 'the field',
'the greens', 'the lakes', 'the valley', 'the villa', 'the world', 'tilal al ghaf', 'town square',
'trade center first', 'trade center second', 'um al sheif', 'um hurair first', 'um hurair second',
'um ramool', 'um suqaim first', 'um suqaim second', 'um suqaim third', 'umm addamin', 'villanova',
'wadi al amardi', 'wadi al safa 2', 'wadi al safa 3', 'wadi al safa 4', 'wadi al safa 5',
'wadi al safa 6', 'wadi al safa 7', 'warsan first', 'warsan fourth', 'yaraah', 'zaabeel first', 'zaabeel second'
];
// Property types for rent
const rentPropertyTypes = [
{ value: 'all', label: 'All Types' },
{ value: 'unit', label: 'Unit' },
{ value: 'villa', label: 'Villa' },
{ value: 'virtual unit', label: 'Virtual Unit' },
{ value: 'land', label: 'Land' },
{ value: 'building', label: 'Building' }
];
// Property types for sale
const salePropertyTypes = [
{ value: 'all', label: 'All Types' },
{ value: 'building', label: 'Building' },
{ value: 'land', label: 'Land' },
{ value: 'unit', label: 'Unit' }
];
// Combined projects list (simplified - you may want to load dynamically)
const projects = [
'014 tower', '08 life residences', '1 residences', '10 oxford by iman', '105 residences by kamdar',
'11 hills park', '15 cascade', '15 northside', '161 jumeirah lane', '17 icon bay',
'171 garden heights', '1wood residence', '2020 marquis', '23 marina',
'29 boulevard', '310 riverside crescent', '311 boulevard by bam eskan',
'empire estates', 'empire heights', 'starz tower by danube', 'azizi feirouz i',
'candace acacia hotel apartments', 'burj al nujoom', 'azizi riviera 35'
// Add more projects as needed
];
let currentType = 'rent';
let currentPage = 1;
let lastPaging = { total: 0, page: null, page_size: 30, total_pages: 1, has_next: false, has_prev: false };
function populateAreas() {
const areaSelect = document.getElementById('area_name');
areas.forEach(area => {
const option = document.createElement('option');
option.value = area;
option.textContent = area.charAt(0).toUpperCase() + area.slice(1).replace(/\b\w/g, l => l.toUpperCase());
areaSelect.appendChild(option);
});
}
function loadProjects() {
const projectSelect = document.getElementById('project');
const sortedProjects = projects.slice().sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
sortedProjects.forEach(project => {
const option = document.createElement('option');
option.value = project;
option.textContent = project.length > 70 ? project.substring(0, 70) + '...' : project;
projectSelect.appendChild(option);
});
}
function updatePropertyTypes() {
const propertyTypeSelect = document.getElementById('property_type');
propertyTypeSelect.innerHTML = '';
const types = currentType === 'rent' ? rentPropertyTypes : salePropertyTypes;
types.forEach(type => {
const option = document.createElement('option');
option.value = type.value;
option.textContent = type.label;
propertyTypeSelect.appendChild(option);
});
}
function updateTypeDependentFields() {
const typeInput = document.getElementById('type');
typeInput.value = currentType;
// Show/hide rooms vs beds
const roomsGroup = document.getElementById('roomsGroup');
const bedsGroup = document.getElementById('bedsGroup');
if (currentType === 'rent') {
roomsGroup.style.display = 'flex';
bedsGroup.style.display = 'none';
document.getElementById('beds').value = 'all';
} else {
roomsGroup.style.display = 'none';
bedsGroup.style.display = 'flex';
document.getElementById('rooms').value = 'all';
}
updatePropertyTypes();
updateTableHeaders();
}
function updateTableHeaders() {
const tableHead = document.getElementById('tableHead');
if (currentType === 'rent') {
tableHead.innerHTML = `
<tr>
<th>rent_id</th>
<th>registration_date</th>
<th>start_date</th>
<th>end_date</th>
<th>version_en</th>
<th>area_en</th>
<th>contract_amount</th>
<th>annual_amount</th>
<th>is_free_hold_en</th>
<th>actual_area</th>
<th>prop_type_en</th>
<th>prop_sub_type_en</th>
<th>rooms</th>
<th>usage_en</th>
<th>nearest_metro_en</th>
<th>nearest_mall_en</th>
<th>nearest_landmark_en</th>
<th>parking</th>
<th>total_properties</th>
<th>master_project_en</th>
<th>project_en</th>
<th>created_at</th>
<th>updated_at</th>
</tr>
`;
} else {
tableHead.innerHTML = `
<tr>
<th>transaction_id</th>
<th>transaction_number</th>
<th>instance_date</th>
<th>group_en</th>
<th>procedure_en</th>
<th>is_offplan_en</th>
<th>is_free_hold_en</th>
<th>usage_en</th>
<th>area_en</th>
<th>prop_type_en</th>
<th>prop_sb_type_en</th>
<th>trans_value</th>
<th>procedure_area</th>
<th>actual_area</th>
<th>rooms_en</th>
<th>parking</th>
<th>nearest_metro_en</th>
<th>nearest_mall_en</th>
<th>nearest_landmark_en</th>
<th>total_buyer</th>
<th>total_seller</th>
<th>master_project_en</th>
<th>project_en</th>
<th>created_at</th>
<th>updated_at</th>
</tr>
`;
}
}
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'block' : 'none';
}
function showError(message) {
const errorDiv = document.getElementById('error');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
function hideError() {
document.getElementById('error').style.display = 'none';
}
function showResults() {
document.getElementById('resultsSection').style.display = 'block';
}
function hideResults() {
document.getElementById('resultsSection').style.display = 'none';
}
function formatDate(dateString) {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
function formatNumber(num, decimals = 0) {
if (num === null || num === undefined) return 'N/A';
return parseFloat(num).toLocaleString('en-US', { maximumFractionDigits: decimals });
}
function formatCurrency(amount) {
if (!amount) return 'N/A';
return 'AED ' + parseFloat(amount).toLocaleString('en-US', { maximumFractionDigits: 0 });
}
async function searchProperties(pageOverride) {
const formData = new FormData(document.getElementById('filtersForm'));
const params = new URLSearchParams();
const type = formData.get('type') || currentType;
const area_name = formData.get('area_name');
const property_type = formData.get('property_type');
const size_min = formData.get('size_min');
const size_max = formData.get('size_max');
const rooms = formData.get('rooms');
const beds = formData.get('beds');
const project = formData.get('project');
const pageSize = formData.get('page_size') || '30';
const page = pageOverride != null ? pageOverride : (formData.get('page') || currentPage || 1);
params.append('type', type);
if (area_name) params.append('area_name', area_name);
if (property_type && property_type !== 'all') params.append('property_type', property_type);
if (size_min && !isNaN(parseFloat(size_min))) params.append('size_min', size_min);
if (size_max && !isNaN(parseFloat(size_max))) params.append('size_max', size_max);
if (type === 'rent' && rooms && rooms !== 'all') {
params.append('rooms', rooms);
} else if (type === 'sale' && beds && beds !== 'all') {
params.append('beds', beds);
}
if (project && project !== 'all') params.append('project', project);
if (page) params.append('page', page);
if (pageSize) params.append('page_size', pageSize);
showLoading(true);
hideError();
hideResults();
try {
const response = await fetch(`${API_BASE}/properties/recent?${params.toString()}`);
const data = await response.json();
if (data.success) {
currentPage = data.data.page || 1;
lastPaging = {
total: data.data.total ?? data.data.count,
page: data.data.page,
page_size: data.data.page_size ?? parseInt(pageSize, 10),
total_pages: data.data.total_pages ?? 1,
has_next: !!data.data.has_next,
has_prev: !!data.data.has_prev
};
displayResults(data.data);
} else {
showError(data.message || 'Failed to fetch properties');
}
} catch (error) {
showError('Network error: ' + error.message);
} finally {
showLoading(false);
}
}
function displayResults(data) {
const dataKey = currentType === 'rent' ? 'rents' : 'transactions';
const results = data[dataKey] || [];
const { count, total, page, page_size, total_pages, has_next, has_prev } = data;
const tbody = document.getElementById('propertiesBody');
const resultsCount = document.getElementById('resultsCount');
const showing = count;
const grandTotal = total ?? count;
resultsCount.textContent = `Showing ${showing} of ${grandTotal} ${currentType === 'rent' ? 'rents' : 'transactions'}`;
const pageInfo = document.getElementById('pageInfo');
if (pageInfo) {
if (page && total_pages) {
pageInfo.textContent = `Page ${page} of ${total_pages}`;
} else {
pageInfo.textContent = '';
}
}
const prevBtn = document.getElementById('prevPage');
const nextBtn = document.getElementById('nextPage');
if (prevBtn) prevBtn.disabled = !(has_prev);
if (nextBtn) nextBtn.disabled = !(has_next);
if (results.length === 0) {
const colCount = document.querySelectorAll('#propertiesTable thead th').length;
tbody.innerHTML = (
'<tr>' +
`<td colspan="${colCount}" class="no-results">` +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">' +
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>' +
'</svg>' +
`<div>No ${currentType === 'rent' ? 'rents' : 'transactions'} found matching your filters</div>` +
'</td>' +
'</tr>'
);
showResults();
return;
}
if (currentType === 'rent') {
tbody.innerHTML = results.map(r => (
'<tr>' +
`<td>${r.rent_id ?? 'N/A'}</td>` +
`<td>${formatDate(r.registration_date)}</td>` +
`<td>${formatDate(r.start_date)}</td>` +
`<td>${formatDate(r.end_date)}</td>` +
`<td>${r.version_en ?? 'N/A'}</td>` +
`<td>${r.area_en ?? 'N/A'}</td>` +
`<td>${formatCurrency(r.contract_amount)}</td>` +
`<td>${formatCurrency(r.annual_amount)}</td>` +
`<td>${r.is_free_hold_en ?? 'N/A'}</td>` +
`<td>${formatNumber(r.actual_area)}</td>` +
`<td>${r.prop_type_en ?? 'N/A'}</td>` +
`<td>${r.prop_sub_type_en ?? 'N/A'}</td>` +
`<td>${r.rooms != null ? formatNumber(r.rooms, 1) : 'N/A'}</td>` +
`<td>${r.usage_en ?? 'N/A'}</td>` +
`<td>${r.nearest_metro_en ?? 'N/A'}</td>` +
`<td>${r.nearest_mall_en ?? 'N/A'}</td>` +
`<td>${r.nearest_landmark_en ?? 'N/A'}</td>` +
`<td>${r.parking != null ? formatNumber(r.parking, 1) : 'N/A'}</td>` +
`<td>${r.total_properties ?? '0'}</td>` +
`<td>${r.master_project_en ?? 'N/A'}</td>` +
`<td>${r.project_en ?? 'N/A'}</td>` +
`<td>${formatDate(r.created_at)}</td>` +
`<td>${formatDate(r.updated_at)}</td>` +
'</tr>'
)).join('');
} else {
tbody.innerHTML = results.map(t => (
'<tr>' +
`<td>${t.transaction_id ?? 'N/A'}</td>` +
`<td>${t.transaction_number ?? 'N/A'}</td>` +
`<td>${formatDate(t.instance_date)}</td>` +
`<td>${t.group_en ?? 'N/A'}</td>` +
`<td>${t.procedure_en ?? 'N/A'}</td>` +
`<td>${t.is_offplan_en ?? 'N/A'}</td>` +
`<td>${t.is_free_hold_en ?? 'N/A'}</td>` +
`<td>${t.usage_en ?? 'N/A'}</td>` +
`<td>${t.area_en ?? 'N/A'}</td>` +
`<td>${t.prop_type_en ?? 'N/A'}</td>` +
`<td>${t.prop_sb_type_en ?? 'N/A'}</td>` +
`<td>${formatCurrency(t.trans_value)}</td>` +
`<td>${t.procedure_area != null ? formatNumber(t.procedure_area) : 'N/A'}</td>` +
`<td>${t.actual_area != null ? formatNumber(t.actual_area) : 'N/A'}</td>` +
`<td>${t.rooms_en ?? 'N/A'}</td>` +
`<td>${t.parking ?? 'N/A'}</td>` +
`<td>${t.nearest_metro_en ?? 'N/A'}</td>` +
`<td>${t.nearest_mall_en ?? 'N/A'}</td>` +
`<td>${t.nearest_landmark_en ?? 'N/A'}</td>` +
`<td>${t.total_buyer ?? '0'}</td>` +
`<td>${t.total_seller ?? '0'}</td>` +
`<td>${t.master_project_en ?? 'N/A'}</td>` +
`<td>${t.project_en ?? 'N/A'}</td>` +
`<td>${formatDate(t.created_at)}</td>` +
`<td>${formatDate(t.updated_at)}</td>` +
'</tr>'
)).join('');
}
showResults();
}
function resetFilters() {
document.getElementById('filtersForm').reset();
document.getElementById('size_min').value = '';
document.getElementById('size_max').value = '';
document.getElementById('sizeValue').textContent = 'All Sizes';
document.getElementById('type_rent').checked = true;
currentType = 'rent';
updateTypeDependentFields();
hideResults();
hideError();
}
document.addEventListener('DOMContentLoaded', () => {
populateAreas();
loadProjects();
updateTypeDependentFields();
// Type selector radio buttons
document.querySelectorAll('input[name="type"]').forEach(radio => {
radio.addEventListener('change', (e) => {
currentType = e.target.value;
updateTypeDependentFields();
hideResults();
hideError();
});
});
// Size slider setup
const sizeMinHidden = document.getElementById('size_min');
const sizeMaxHidden = document.getElementById('size_max');
const sizeMinRange = document.getElementById('size_min_range');
const sizeMaxRange = document.getElementById('size_max_range');
const sizeFill = document.getElementById('sizeFill');
const sizeValue = document.getElementById('sizeValue');
const MIN_VAL = parseFloat(sizeMinRange.min);
const MAX_VAL = parseFloat(sizeMinRange.max);
const STEP = parseFloat(sizeMinRange.step) || 100;
function updateSizeLabel() {
const min = parseFloat(sizeMinRange.value);
const max = parseFloat(sizeMaxRange.value);
const hasMin = !isNaN(min);
const hasMax = !isNaN(max);
if (!hasMin && !hasMax) {
sizeValue.textContent = 'All Sizes';
} else if (hasMin && hasMax) {
sizeValue.textContent = `${min.toLocaleString('en-US')} - ${max.toLocaleString('en-US')} sq.ft`;
} else if (hasMin) {
sizeValue.textContent = `${min.toLocaleString('en-US')} sq.ft`;
} else {
sizeValue.textContent = `${max.toLocaleString('en-US')} sq.ft`;
}
sizeMinHidden.value = (min > MIN_VAL) ? min : '';
sizeMaxHidden.value = (max < MAX_VAL) ? max : '';
const left = ((Math.max(MIN_VAL, Math.min(min, max)) - MIN_VAL) / (MAX_VAL - MIN_VAL)) * 100;
const right = ((Math.max(MIN_VAL, Math.max(min, max)) - MIN_VAL) / (MAX_VAL - MIN_VAL)) * 100;
sizeFill.style.left = `${left}%`;
sizeFill.style.width = `${Math.max(0, right - left)}%`;
}
function clampRanges() {
if (parseFloat(sizeMinRange.value) > parseFloat(sizeMaxRange.value) - STEP) {
sizeMinRange.value = (parseFloat(sizeMaxRange.value) - STEP).toString();
}
if (parseFloat(sizeMaxRange.value) < parseFloat(sizeMinRange.value) + STEP) {
sizeMaxRange.value = (parseFloat(sizeMinRange.value) + STEP).toString();
}
}
sizeMinRange.addEventListener('input', () => { clampRanges(); updateSizeLabel(); });
sizeMaxRange.addEventListener('input', () => { clampRanges(); updateSizeLabel(); });
function bringMinToFront() {
sizeMinRange.style.zIndex = '6';
sizeMaxRange.style.zIndex = '5';
}
function bringMaxToFront() {
sizeMinRange.style.zIndex = '5';
sizeMaxRange.style.zIndex = '6';
}
['mousedown','pointerdown','touchstart'].forEach(evt => {
sizeMinRange.addEventListener(evt, bringMinToFront, { passive: true });
sizeMaxRange.addEventListener(evt, bringMaxToFront, { passive: true });
});
sizeMinRange.value = MIN_VAL;
sizeMaxRange.value = MAX_VAL;
updateSizeLabel();
// Form submission
document.getElementById('filtersForm').addEventListener('submit', async (e) => {
e.preventDefault();
currentPage = 1;
const pageInput = document.getElementById('page');
if (pageInput) pageInput.value = '1';
await searchProperties(1);
});
// Reset button
const resetBtn = document.getElementById('resetBtn');
if (resetBtn) {
resetBtn.addEventListener('click', resetFilters);
}
// Pagination buttons
const prevBtn = document.getElementById('prevPage');
const nextBtn = document.getElementById('nextPage');
if (prevBtn) {
prevBtn.addEventListener('click', async () => {
if (lastPaging.has_prev && currentPage > 1) {
currentPage -= 1;
const pageInput2 = document.getElementById('page');
if (pageInput2) pageInput2.value = String(currentPage);
await searchProperties(currentPage);
}
});
}
if (nextBtn) {
nextBtn.addEventListener('click', async () => {
if (lastPaging.has_next) {
currentPage += 1;
const pageInput3 = document.getElementById('page');
if (pageInput3) pageInput3.value = String(currentPage);
await searchProperties(currentPage);
}
});
}
});

542
public/properties.html Normal file
View File

@ -0,0 +1,542 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Properties - Dubai DLD Analytics</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
margin-bottom: 30px;
text-align: center;
}
.header h1 {
color: #333;
font-size: 2.5rem;
margin-bottom: 10px;
}
.header p {
color: #666;
font-size: 1.1rem;
}
.type-selector {
background: white;
padding: 25px;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
margin-bottom: 30px;
text-align: center;
}
.type-selector h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.3rem;
}
.type-options {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.type-option {
flex: 1;
min-width: 200px;
max-width: 300px;
}
.type-option input[type="radio"] {
display: none;
}
.type-option label {
display: block;
padding: 20px;
border: 3px solid #e0e0e0;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
background: white;
text-align: center;
font-weight: 600;
font-size: 1.1rem;
color: #666;
}
.type-option input[type="radio"]:checked + label {
border-color: #f5576c;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
transform: scale(1.05);
box-shadow: 0 5px 15px rgba(245, 87, 108, 0.3);
}
.type-option label:hover {
border-color: #f5576c;
transform: translateY(-2px);
}
.filters-section {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
margin-bottom: 30px;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 25px;
}
.filter-group {
display: flex;
flex-direction: column;
}
.filter-group label {
font-weight: 600;
color: #333;
margin-bottom: 8px;
font-size: 0.95rem;
}
.filter-group select,
.filter-group input {
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s ease;
background: white;
}
.filter-group select:focus,
.filter-group input:focus {
outline: none;
border-color: #f5576c;
box-shadow: 0 0 0 3px rgba(245, 87, 108, 0.1);
}
.slider-container {
padding: 10px 0;
}
.slider-wrapper {
display: flex;
align-items: center;
gap: 15px;
}
.range-container { position: relative; width: 100%; height: 36px; }
.range-track {
position: absolute; top: 50%; transform: translateY(-50%);
height: 8px; width: 100%; border-radius: 5px; background: #e0e0e0;
}
.range-fill { position: absolute; top: 50%; transform: translateY(-50%); height: 8px; border-radius: 5px; background: linear-gradient(135deg,#f093fb,#f5576c); }
.slider { position: absolute; left: 0; right: 0; width: 100%; pointer-events: none; background: none; height: 36px; }
.slider input[type=range] { pointer-events: none; -webkit-appearance: none; appearance: none; width: 100%; background: transparent; height: 36px; position: absolute; top: 50%; transform: translateY(-50%); }
.slider input[type=range]:nth-child(1) { z-index: 3; }
.slider input[type=range]:nth-child(2) { z-index: 4; }
.slider input[type=range]::-webkit-slider-thumb { pointer-events: all; -webkit-appearance: none; appearance: none; width: 20px; height: 20px; border-radius: 50%; background: #f5576c; cursor: pointer; box-shadow: 0 0 0 3px rgba(245,87,108,.2); }
.slider input[type=range]::-moz-range-thumb { pointer-events: all; width: 20px; height: 20px; border-radius: 50%; background: #f5576c; cursor: pointer; border: none; box-shadow: 0 0 0 3px rgba(245,87,108,.2); }
.slider-value {
min-width: 120px;
text-align: center;
padding: 8px 12px;
background: #f5f5f5;
border-radius: 6px;
font-weight: 600;
color: #333;
}
/* Make size filter span full row for longer slider */
.filter-group.size-group { grid-column: 1 / -1; }
.button-group {
display: flex;
gap: 15px;
margin-top: 20px;
flex-wrap: wrap;
}
.btn {
padding: 14px 30px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
flex: 1;
min-width: 200px;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(245, 87, 108, 0.4);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-2px);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
font-size: 1.2rem;
display: none;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
display: none;
}
.results-section {
background: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
flex-wrap: wrap;
gap: 15px;
}
.results-count {
font-size: 1.3rem;
font-weight: 600;
color: #333;
}
.table-container {
overflow-x: auto;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 800px;
}
thead {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
th {
padding: 15px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 15px;
border-bottom: 1px solid #e0e0e0;
}
tbody tr:hover {
background: #f8f9fa;
transition: background 0.2s ease;
}
tbody tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.badge-primary {
background: #f5576c;
color: white;
}
.badge-success {
background: #28a745;
color: white;
}
.badge-warning {
background: #ffc107;
color: #333;
}
.no-results {
text-align: center;
padding: 60px 20px;
color: #999;
}
.no-results svg {
width: 80px;
height: 80px;
margin-bottom: 20px;
opacity: 0.5;
}
@media (max-width: 768px) {
.filters-grid {
grid-template-columns: 1fr;
}
.header h1 {
font-size: 1.8rem;
}
.slider-wrapper {
flex-direction: column;
align-items: stretch;
}
.results-header {
flex-direction: column;
align-items: flex-start;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
}
.type-options {
flex-direction: column;
}
}
@media (max-width: 345px) {
body {
padding: 10px;
}
.header h1 {
font-size: 1.5rem;
}
.filters-section,
.results-section {
padding: 15px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🏘️ Properties Search</h1>
<p>Filter and explore properties for rent or sale in Dubai</p>
</div>
<div class="type-selector">
<h2>Select Property Type</h2>
<div class="type-options">
<div class="type-option">
<input type="radio" id="type_rent" name="type" value="rent" checked>
<label for="type_rent">🏠 Rent</label>
</div>
<div class="type-option">
<input type="radio" id="type_sale" name="type" value="sale">
<label for="type_sale">💰 Sale</label>
</div>
</div>
</div>
<div class="filters-section">
<h2 style="margin-bottom: 20px; color: #333;">Filters</h2>
<form id="filtersForm">
<input type="hidden" id="type" name="type" value="rent">
<div class="filters-grid">
<div class="filter-group size-group">
<label for="area_name">Area</label>
<select id="area_name" name="area_name">
<option value="">All Areas</option>
</select>
</div>
<div class="filter-group">
<label for="property_type">Property Type</label>
<select id="property_type" name="property_type">
<option value="all">All Types</option>
</select>
</div>
<div class="filter-group" id="roomsGroup">
<label for="rooms">Rooms</label>
<select id="rooms" name="rooms">
<option value="all">All</option>
<option value="1">1.0</option>
<option value="2">2.0</option>
<option value="3">3.0</option>
<option value="4">4.0</option>
<option value="5">5.0</option>
</select>
</div>
<div class="filter-group" id="bedsGroup" style="display: none;">
<label for="beds">Bedrooms</label>
<select id="beds" name="beds">
<option value="all">All</option>
<option value="studio">Studio</option>
<option value="1 b/r">1 B/R</option>
<option value="2 b/r">2 B/R</option>
<option value="3 b/r">3 B/R</option>
<option value="4 b/r">4 B/R</option>
<option value="5 b/r">5 B/R</option>
<option value="6 b/r">6 B/R</option>
<option value="7 b/r">7 B/R</option>
<option value="10 b/r">10 B/R</option>
<option value="penthouse">Penthouse</option>
<option value="office">Office</option>
<option value="shop">Shop</option>
<option value="gym">Gym</option>
<option value="single room">Single Room</option>
<option value="null">NULL</option>
</select>
</div>
<div class="filter-group">
<label for="project">Project</label>
<select id="project" name="project">
<option value="all">All Projects</option>
</select>
</div>
<div class="filter-group">
<label for="page_size">Page Size</label>
<select id="page_size" name="page_size">
<option value="10">10</option>
<option value="20">20</option>
<option value="30" selected>30</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<input type="hidden" id="page" name="page" value="1">
</div>
<div class="filter-group">
<label>Size (sq. ft)</label>
<div class="slider-container">
<div class="slider-wrapper" style="width:100%">
<div class="range-container">
<div class="range-track"></div>
<div class="range-fill" id="sizeFill" style="left:0%; width:100%"></div>
<div class="slider">
<input type="range" id="size_min_range" min="1.0" max="1715752.11" step="100" value="1.0">
<input type="range" id="size_max_range" min="1.0" max="1715752.11" step="100" value="1715752.11">
</div>
</div>
<div class="slider-value" id="sizeValue">All Sizes</div>
</div>
<input type="hidden" id="size_min" name="size_min" value="">
<input type="hidden" id="size_max" name="size_max" value="">
</div>
</div>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">🔍 Search Properties</button>
<button type="button" class="btn btn-secondary" id="resetBtn">🔄 Reset Filters</button>
</div>
</form>
</div>
<div class="loading" id="loading">
🔍 Fetching properties...
</div>
<div class="error" id="error"></div>
<div class="results-section" id="resultsSection" style="display: none;">
<div class="results-header">
<div class="results-count" id="resultsCount"></div>
<div class="pagination-controls" style="display:flex;gap:10px;align-items:center;">
<button type="button" class="btn btn-secondary" id="prevPage">Prev</button>
<div id="pageInfo" style="font-weight:600;color:#333;"></div>
<button type="button" class="btn btn-secondary" id="nextPage">Next</button>
</div>
</div>
<div class="table-container" id="tableContainer">
<table id="propertiesTable">
<thead id="tableHead">
</thead>
<tbody id="propertiesBody">
</tbody>
</table>
</div>
</div>
</div>
<script src="/js/properties.js"></script>
</body>
</html>

View File

@ -605,4 +605,368 @@ router.get('/rents/recent', async (req, res) => {
}
});
// Unified endpoint for both rent and sale queries
// Works exactly like /api/transactions/recent for type=sale
// Works exactly like /api/rents/recent for type=rent
router.get('/properties/recent', async (req, res) => {
try {
// Extract all possible parameters
const {
type, // Required: 'rent' or 'sale'
area_name,
property_type,
size, // Legacy parameter for sale (upper bound)
size_min,
size_max,
rooms, // For rent: decimal (2.0, 3.0). For sale: accepts string ("3", "studio", "3 b/r")
beds, // Alternative parameter for sale (backward compatibility)
project,
page,
page_size,
limit
} = req.query;
// ============================================
// VALIDATION: Type parameter (required)
// ============================================
if (!type || typeof type !== 'string') {
return res.status(400).json({
success: false,
message: 'Type parameter is required and must be either "rent" or "sale"'
});
}
const normalizedType = type.toLowerCase().trim();
if (normalizedType !== 'rent' && normalizedType !== 'sale') {
return res.status(400).json({
success: false,
message: 'Type parameter must be either "rent" or "sale"'
});
}
const isRent = normalizedType === 'rent';
// ============================================
// CONFIGURATION: Type-specific settings
// ============================================
const config = {
tableName: isRent ? 'rents' : 'transactions',
dateColumn: isRent ? 'registration_date' : 'instance_date',
idColumn: isRent ? 'rent_id' : 'transaction_id',
dataKey: isRent ? 'rents' : 'transactions',
propSubTypeColumn: isRent ? 'prop_sub_type_en' : 'prop_sb_type_en',
roomsColumn: isRent ? 'rooms' : 'rooms_en'
};
// ============================================
// VALIDATION: Limit parameter
// ============================================
let limitNum = undefined;
if (limit !== undefined && limit !== null && `${limit}`.trim() !== '') {
const parsed = parseInt(limit, 10);
if (isNaN(parsed) || parsed < 1) {
return res.status(400).json({
success: false,
message: 'Limit must be a positive integer when provided'
});
}
limitNum = parsed;
}
// ============================================
// BUILD WHERE CLAUSE: Base condition
// ============================================
let baseWhere = ' WHERE 1=1';
const params = [];
// ============================================
// FILTER: Area name (case-insensitive, partial match)
// ============================================
if (area_name !== undefined && area_name !== null && typeof area_name === 'string' && area_name.trim() !== '') {
baseWhere += ' AND LOWER(area_en) LIKE ?';
params.push(`%${area_name.toLowerCase().trim()}%`);
}
// ============================================
// FILTER: Property type (case-insensitive, partial match)
// ============================================
if (property_type !== undefined &&
property_type !== null &&
typeof property_type === 'string' &&
property_type.trim() !== '' &&
property_type.toLowerCase().trim() !== 'all') {
baseWhere += ` AND (LOWER(prop_type_en) LIKE ? OR LOWER(${config.propSubTypeColumn}) LIKE ?)`;
const propType = `%${property_type.toLowerCase().trim()}%`;
params.push(propType, propType);
}
// ============================================
// FILTER: Size (area) - Different logic for rent vs sale
// ============================================
if (isRent) {
// For rents: only size_min and size_max (no legacy 'size')
const minNum = size_min !== undefined && size_min !== null ? parseFloat(size_min) : undefined;
const maxNum = size_max !== undefined && size_max !== null ? parseFloat(size_max) : undefined;
const hasMin = minNum !== undefined && !isNaN(minNum) && minNum >= 0;
const hasMax = maxNum !== undefined && !isNaN(maxNum) && maxNum >= 0;
if (hasMin && hasMax) {
if (minNum <= maxNum) {
baseWhere += ' AND actual_area BETWEEN ? AND ?';
params.push(minNum, maxNum);
} else {
// Invalid range: min > max
return res.status(400).json({
success: false,
message: 'size_min must be less than or equal to size_max'
});
}
} else if (hasMin) {
baseWhere += ' AND actual_area >= ?';
params.push(minNum);
} else if (hasMax) {
baseWhere += ' AND actual_area <= ?';
params.push(maxNum);
}
} else {
// For sales: support size_min/size_max AND legacy 'size' as upper bound
const minNum = size_min !== undefined && size_min !== null ? parseFloat(size_min) : undefined;
const maxNum = size_max !== undefined && size_max !== null
? parseFloat(size_max)
: (size !== undefined && size !== null ? parseFloat(size) : undefined);
const hasMin = minNum !== undefined && !isNaN(minNum) && minNum >= 0;
const hasMax = maxNum !== undefined && !isNaN(maxNum) && maxNum >= 0;
if (hasMin && hasMax) {
if (minNum <= maxNum) {
baseWhere += ' AND actual_area BETWEEN ? AND ?';
params.push(minNum, maxNum);
} else {
return res.status(400).json({
success: false,
message: 'size_min must be less than or equal to size_max (or size)'
});
}
} else if (hasMin) {
baseWhere += ' AND actual_area >= ?';
params.push(minNum);
} else if (hasMax) {
baseWhere += ' AND actual_area <= ?';
params.push(maxNum);
}
}
// ============================================
// FILTER: Rooms/Beds - Different handling for rent vs sale
// ============================================
// For sale: accept both 'rooms' and 'beds' for backward compatibility
// For rent: only accept 'rooms'
const roomsOrBeds = isRent ? rooms : (rooms || beds);
if (roomsOrBeds !== undefined &&
roomsOrBeds !== null &&
roomsOrBeds !== '' &&
roomsOrBeds.toString().toLowerCase().trim() !== 'all') {
if (isRent) {
// For rents: use decimal value (2.0, 3.0, etc.) - exact match
const roomsNum = parseFloat(roomsOrBeds);
if (!isNaN(roomsNum) && roomsNum >= 0) {
baseWhere += ` AND ${config.roomsColumn} = ?`;
params.push(roomsNum);
}
// If invalid, silently ignore (matches original behavior)
} else {
// For sales (transactions): use string format handling like beds
const bedsStr = roomsOrBeds.toString().trim();
const bedsLower = bedsStr.toLowerCase();
if (bedsLower === 'studio') {
baseWhere += ` AND LOWER(${config.roomsColumn}) = ?`;
params.push('studio');
} else if (bedsLower === 'null' || bedsStr === '') {
baseWhere += ` AND (${config.roomsColumn} IS NULL OR ${config.roomsColumn} = "")`;
} else {
// Match formats like "3 b/r", "3.0", "3"
baseWhere += ` AND (${config.roomsColumn} LIKE ? OR ${config.roomsColumn} = ? OR ${config.roomsColumn} = ?)`;
params.push(`%${bedsStr}%`, bedsStr, `${bedsStr}.0`);
}
}
}
// ============================================
// FILTER: Project name (case-insensitive, partial match)
// ============================================
if (project !== undefined &&
project !== null &&
typeof project === 'string' &&
project.trim() !== '' &&
project.toLowerCase().trim() !== 'all') {
baseWhere += ' AND (LOWER(project_en) LIKE ? OR LOWER(master_project_en) LIKE ?)';
const projectName = `%${project.toLowerCase().trim()}%`;
params.push(projectName, projectName);
}
// ============================================
// PAGINATION: Calculate pagination parameters
// ============================================
// Match original endpoints exactly
const rawPageSize = page_size ?? undefined;
const wantsPagination = (page !== undefined) || (rawPageSize !== undefined) || (limitNum !== undefined);
const defaultPage = 1;
const defaultPageSize = 30;
// Parse page number with validation
let pageNum = defaultPage;
if (page !== undefined && page !== null) {
const parsed = parseInt(page, 10);
if (!isNaN(parsed) && parsed >= 1) {
pageNum = parsed;
}
}
// Parse page size with validation
let pageSizeNum = defaultPageSize;
if (wantsPagination) {
if (rawPageSize !== undefined && rawPageSize !== null) {
const parsed = parseInt(rawPageSize, 10);
if (!isNaN(parsed) && parsed >= 1) {
pageSizeNum = parsed;
}
} else if (limitNum !== undefined) {
pageSizeNum = limitNum;
}
}
const offsetNum = (pageNum - 1) * pageSizeNum;
// ============================================
// SQL GENERATION: Build query with pagination
// ============================================
const orderBy = ` ORDER BY ${config.dateColumn} DESC, ${config.idColumn} DESC`;
let dataSql;
let total = null;
if (!wantsPagination) {
// No pagination: return all results
dataSql = `SELECT * FROM ${config.tableName} ${baseWhere} ${orderBy}`;
} else if (limitNum !== undefined && page === undefined && page_size === undefined) {
// Legacy behavior: only limit provided (no paging), apply LIMIT without OFFSET
dataSql = `SELECT * FROM ${config.tableName} ${baseWhere} ${orderBy} LIMIT ${limitNum}`;
} else {
// Full pagination with COUNT
const countSql = `SELECT COUNT(*) AS total FROM ${config.tableName} ${baseWhere}`;
try {
const countRows = await database.query(countSql, params);
total = countRows && countRows[0] && countRows[0].total !== undefined
? parseInt(countRows[0].total, 10)
: 0;
} catch (countError) {
console.error('❌ Count query error:', countError);
// Continue without total count if count fails
total = 0;
}
dataSql = `SELECT * FROM ${config.tableName} ${baseWhere} ${orderBy} LIMIT ${pageSizeNum} OFFSET ${offsetNum}`;
}
// ============================================
// LOGGING: Debug information
// ============================================
const logData = {
type: normalizedType,
area_name: area_name || null,
property_type: property_type || null,
rooms: rooms || null,
beds: beds || null,
project: project || null,
limit: limitNum ?? null,
page: pageNum,
page_size: pageSizeNum
};
if (isRent) {
logData.size_min = size_min || null;
logData.size_max = size_max || null;
} else {
logData.size = size || null;
logData.size_min = size_min || null;
logData.size_max = size_max || null;
}
console.log(`🔍 Fetching recent ${normalizedType} properties with filters:`, logData);
// ============================================
// EXECUTE QUERY: Fetch data
// ============================================
const results = await database.query(dataSql, params);
// ============================================
// CALCULATE PAGINATION METADATA
// ============================================
const totalPages = (total !== null && total > 0)
? Math.max(1, Math.ceil(total / pageSizeNum))
: 1;
// ============================================
// BUILD FILTERS OBJECT: Match original endpoints exactly
// ============================================
const filters = {
type: normalizedType,
area_name: area_name || null,
property_type: property_type || null,
project: project || null,
limit: limitNum ?? null,
page: total !== null ? pageNum : null,
page_size: total !== null ? pageSizeNum : (limitNum !== undefined ? limitNum : null)
};
// Add type-specific filters
if (isRent) {
filters.size_min = size_min || null;
filters.size_max = size_max || null;
filters.rooms = rooms || null;
} else {
filters.size = size || null;
filters.size_min = size_min || null;
filters.size_max = size_max || null;
filters.rooms = rooms || null;
filters.beds = beds || null;
}
// ============================================
// RESPONSE: Return formatted data
// ============================================
res.json({
success: true,
data: {
[config.dataKey]: results,
type: normalizedType,
count: results.length,
total: total !== null ? total : results.length,
page: total !== null ? pageNum : null,
page_size: total !== null ? pageSizeNum : (limitNum !== undefined ? limitNum : null),
total_pages: total !== null ? totalPages : 1,
has_next: total !== null ? pageNum < totalPages : false,
has_prev: total !== null ? pageNum > 1 : false,
filters: filters
}
});
} catch (error) {
// ============================================
// ERROR HANDLING: Comprehensive error response
// ============================================
const queryType = (req.query?.type || 'properties').toLowerCase();
console.error(`${queryType} query error:`, error);
res.status(500).json({
success: false,
message: `Failed to retrieve ${queryType}`,
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
});
}
});
module.exports = router;

View File

@ -107,7 +107,12 @@ class ChartFormatter {
} else if (intent === 'compare') {
description = `Comparison analysis across ${sqlResult.length} areas`;
} else if (intent === 'summary') {
// Check if this is a project summary query with metric-based data
if (sqlResult && sqlResult.length > 0 && sqlResult[0].metric_name) {
description = this.generateProjectSummaryDescription(sqlResult);
} else {
description = `Summary of ${sqlResult.length} projects`;
}
} else {
description = `Analysis results for: ${original_query}`;
}
@ -845,6 +850,87 @@ class ChartFormatter {
`${areaName} demonstrates strong commercial leasing activity with ${count} transactions, averaging ${this.formatCurrency(avgRent)} annually. The area offers diverse commercial opportunities with good connectivity and business-friendly infrastructure.`;
}
generateProjectSummaryDescription(sqlResult) {
if (!sqlResult || sqlResult.length === 0) {
return 'Project summary data is not available.';
}
// Extract key metrics from the metric-based data
const metrics = {};
sqlResult.forEach(row => {
const metricName = row.metric_name?.toLowerCase() || '';
const value = row.metric_value || 0;
// Match metric names (they may have numbers like "1. Total Transactions")
if (metricName.includes('total transactions') && !metricName.includes('off-plan') && !metricName.includes('ready') && !metricName.includes('residential')) {
metrics.totalTransactions = value;
} else if (metricName.includes('total transaction value')) {
metrics.totalValue = value;
} else if (metricName.includes('average transaction value')) {
metrics.avgValue = value;
} else if (metricName.includes('unique projects') || metricName.includes('project count')) {
metrics.uniqueProjects = value;
} else if (metricName.includes('off-plan')) {
metrics.offPlan = value;
} else if (metricName.includes('ready properties') || metricName.includes('ready')) {
metrics.ready = value;
} else if (metricName.includes('residential transactions')) {
metrics.residential = value;
} else if (metricName.includes('average area')) {
metrics.avgArea = value;
} else if (metricName.includes('unique areas') || metricName.includes('area count')) {
metrics.uniqueAreas = value;
}
});
let description = 'Project Overview: ';
// Total transactions
if (metrics.totalTransactions) {
const totalTxns = Math.round(metrics.totalTransactions);
description += `The database contains ${totalTxns.toLocaleString()} total transactions `;
}
// Total value
if (metrics.totalValue) {
description += `with a combined transaction value of ${this.formatCurrency(metrics.totalValue)}. `;
}
// Unique projects
if (metrics.uniqueProjects) {
const uniqueProj = Math.round(metrics.uniqueProjects);
description += `There are ${uniqueProj.toLocaleString()} unique projects `;
}
// Unique areas
if (metrics.uniqueAreas) {
const uniqueAreas = Math.round(metrics.uniqueAreas);
description += `across ${uniqueAreas} different areas. `;
}
// Average transaction value
if (metrics.avgValue) {
description += `The average transaction value is ${this.formatCurrency(metrics.avgValue)}. `;
}
// Off-plan vs Ready
if (metrics.offPlan && metrics.ready && metrics.totalTransactions) {
const offPlanPercent = Math.round((metrics.offPlan / metrics.totalTransactions) * 100);
const readyPercent = Math.round((metrics.ready / metrics.totalTransactions) * 100);
description += `The market shows ${offPlanPercent}% off-plan transactions and ${readyPercent}% ready properties, `;
description += `indicating a balanced mix of development stages. `;
}
// Residential focus
if (metrics.residential && metrics.totalTransactions) {
const residentialPercent = Math.round((metrics.residential / metrics.totalTransactions) * 100);
description += `Residential transactions represent ${residentialPercent}% of all transactions, `;
description += `highlighting the strong residential market focus.`;
}
return description;
}
getResidentialLeasingExplanation(areaName, count, avgRent, avgArea) {
const explanations = {
'business bay': `Business Bay attracts residents due to its modern residential towers, waterfront living, and proximity to major business districts. The area offers luxury apartments with world-class amenities and excellent connectivity.`,