property end point craeted
This commit is contained in:
parent
bac20a0ffb
commit
a931d61758
1144
Dubai_DLD_Properties_API_Complete.postman_collection.json
Normal file
1144
Dubai_DLD_Properties_API_Complete.postman_collection.json
Normal file
File diff suppressed because it is too large
Load Diff
539
public/js/properties.js
Normal file
539
public/js/properties.js
Normal 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
542
public/properties.html
Normal 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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -102,12 +102,17 @@ class ChartFormatter {
|
||||
if (fallbackReason) {
|
||||
description += ` (${fallbackReason})`;
|
||||
}
|
||||
} else if (intent === 'average') {
|
||||
} else if (intent === 'average') {
|
||||
description = `Average analysis for ${areas && areas.length > 0 ? areas.join(', ') : 'all areas'}`;
|
||||
} else if (intent === 'compare') {
|
||||
description = `Comparison analysis across ${sqlResult.length} areas`;
|
||||
} else if (intent === 'summary') {
|
||||
description = `Summary of ${sqlResult.length} projects`;
|
||||
// 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.`,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user