Merge branch 'Preorder/add-new-map' into 'master'

added new adbrimofcp map

See merge request fronk/thetool!1751
This commit is contained in:
Luca Haid
2025-09-16 10:19:38 +00:00
3 changed files with 153 additions and 64 deletions

View File

@@ -1359,7 +1359,7 @@ ORDER BY
h.id AS hausnummer_id, h.gps_lat, h.gps_long, h.rimo_type, h.rimo_op_state, h.rimo_ex_state, h.hausnummer,
s.name AS strasse_name, plz.plz AS plz_name, o.name AS ortschaft_name,
COUNT(DISTINCT we.id) AS wohneinheit_count,
COUNT(DISTINCT pr.id) AS preorder_count
COUNT(DISTINCT ps.id) AS preorder_count
FROM `{$addressDbName}`.`Hausnummer` AS h
LEFT JOIN `{$addressDbName}`.`Wohneinheit` AS we ON h.id = we.hausnummer_id
LEFT JOIN `{$fronkDbName}`.`Preorder` AS pr ON we.id = pr.adb_wohneinheit_id AND pr.preordercampaign_id = {$safeCampaignId} AND pr.deleted = 0

View File

@@ -1,5 +1,5 @@
.preorder-map-container {
height: 100%; /* Changed from 85vh to fill parent */
height: 100%;
width: 100%;
}
@@ -12,21 +12,25 @@
flex-wrap: wrap;
}
/* Added hover effect for custom-styled buttons */
.map-filter-container .btn {
transition: opacity 0.15s ease-in-out;
}
.map-filter-container .btn:hover {
opacity: 0.85;
}
.marker-label {
/* This is now the outer, transparent container provided by Leaflet. */
/* We remove its background and border to let the inner div's style show through. */
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
/* These properties are for Leaflet's positioning and events */
z-index: 1000;
pointer-events: none;
transition: visibility 0.2s, opacity 0.2s linear;
}
/* Base style for the new inner div that holds the content */
.tooltip-content-wrapper {
background-color: rgba(255, 255, 255, 0.85);
border: 1px solid #ccc;
@@ -40,29 +44,24 @@
white-space: nowrap;
}
/* Style for greenfield markers with at least one preorder */
.tooltip-content-wrapper.marker-label-highlight {
background-color: rgba(248, 215, 218, 0.9); /* Soft red */
background-color: rgba(248, 215, 218, 0.9);
border-color: #e57373;
color: #721c24;
}
/* Style for markers where preorders match housing units */
.tooltip-content-wrapper.marker-label-saturated {
background-color: rgba(212, 237, 218, 0.9); /* Light green */
background-color: rgba(212, 237, 218, 0.9);
border-color: #81c784;
color: #155724;
}
/* * This rule is now more specific to ensure it overrides Leaflet's default styles.
* It targets the DIV element that has BOTH .leaflet-marker-icon AND .custom-div-icon classes.
*/
div.leaflet-marker-icon.custom-div-icon {
background: transparent !important;
border: none !important;
}
/* Base style for all markers */
/* Base style for all RIMO markers */
.rimo-marker {
display: flex;
justify-content: center;
@@ -80,6 +79,22 @@ div.leaflet-marker-icon.custom-div-icon {
color: white;
}
/* --- New Styles for FCP Markers --- */
.fcp-marker {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: #ffc107; /* Yellow */
color: #333;
font-size: 11px;
font-weight: bold;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.4);
}
/* Specific styles for each rimo_type */
.marker-greenfield { background-color: #28a745; }
.marker-residential { background-color: #007bff; }

View File

@@ -2,6 +2,7 @@ Vue.component('PreorderRimoTypeMap', {
data() {
return {
mapMarkers: [],
fcpMarkers: [], // For FCP data
isLoading: false,
window,
fetchUrl: window.TT_CONFIG.BASE_PATH + '/Preorder/RimoTypeMapData',
@@ -12,35 +13,47 @@ Vue.component('PreorderRimoTypeMap', {
disableClusteringAtZoom: 17,
}
},
mapInstance: null, // Reference to the map instance
activeFilters: [], // To store active rimo_type filters
filterOptions: [
{ value: 'greenfield', text: 'Greenfield', icon: 'fas fa-tree' },
{ value: 'residential', text: 'Wohngebiet', icon: 'fas fa-home' },
{ value: 'company', text: 'Gewerbe', icon: 'fas fa-building' },
{ value: 'multiple-dwelling', text: 'Mehrfamilienhaus', icon: 'fas fa-city' },
{ value: 'public', text: 'Öffentlich', icon: 'fas fa-school' },
{ value: 'other', text: 'Andere', icon: 'fas fa-question-circle' }
]
mapInstance: null,
activeFilters: [],
// Single source of truth for RIMO type definitions
rimoTypeDefs: {
greenfield: { text: 'Greenfield', icon: 'fas fa-tree', color: '#28a745' },
residential: { text: 'Wohngebiet', icon: 'fas fa-home', color: '#007bff' },
company: { text: 'Gewerbe', icon: 'fas fa-building', color: '#ffc107' },
'multiple-dwelling': { text: 'Mehrfamilienhaus', icon: 'fas fa-city', color: '#6f42c1' },
public: { text: 'Öffentlich', icon: 'fas fa-school', color: '#17a2b8' },
other: { text: 'Andere', icon: 'fas fa-question-circle', color: '#6c757d' }
}
};
},
computed: {
/**
* Filters markers based on the activeFilters array.
* If no filters are active, all markers are shown.
* Dynamically generates filter options from the rimoTypeDefs object.
*/
filterOptions() {
return Object.entries(this.rimoTypeDefs).map(([value, defs]) => ({
value,
text: defs.text,
icon: defs.icon
}));
},
/**
* Combines filtered RIMO markers with all FCP markers for map display.
* FCPs are always visible, regardless of filters.
*/
filteredMapMarkers() {
if (this.activeFilters.length === 0) {
return this.mapMarkers;
}
return this.mapMarkers.filter(marker => this.activeFilters.includes(marker.rimoType));
const rimoMarkers = this.activeFilters.length === 0
? this.mapMarkers
: this.mapMarkers.filter(marker => this.activeFilters.includes(marker.rimoType));
return [...rimoMarkers, ...this.fcpMarkers];
}
},
async created() {
const urlParams = new URLSearchParams(window.location.search);
this.selectedCampaign = urlParams.get('preordercampaign_id');
if (this.selectedCampaign) {
await this.fetchAndPrepareData();
await this.fetchAllMapData();
}
},
mounted() {
@@ -59,22 +72,75 @@ Vue.component('PreorderRimoTypeMap', {
}
},
methods: {
async fetchAndPrepareData() {
/**
* Main data fetching orchestrator. Fetches RIMO and FCP data in parallel.
*/
async fetchAllMapData() {
if (!this.selectedCampaign) return;
this.isLoading = true;
this.mapMarkers = [];
this.fcpMarkers = [];
try {
await Promise.all([
this.fetchRimoData(),
this.fetchFCPData()
]);
} catch (err) {
console.error("Failed to load map data:", err);
// A general error message is sufficient as specific ones are shown by sub-methods.
} finally {
this.isLoading = false;
}
},
/**
* Fetches and processes RIMO location data.
*/
async fetchRimoData() {
try {
const response = await axios.post(this.fetchUrl, { campaignId: this.selectedCampaign });
if (response.data.success && Array.isArray(response.data.data)) {
this.mapMarkers = this.processData(response.data.data);
} else {
window.notify('error', 'Ungültiges Datenformat von der API empfangen.');
window.notify('error', 'Ungültiges RIMO-Datenformat von der API empfangen.');
}
} catch (err) {
window.notify('error', 'Laden der Kartendaten fehlgeschlagen.');
} finally {
this.isLoading = false;
window.notify('error', 'Laden der RIMO-Kartendaten fehlgeschlagen.');
throw err; // Re-throw to be caught by the orchestrator
}
},
/**
* Fetches and processes FCP (Fiber Connection Point) data.
*/
async fetchFCPData() {
try {
const url = `${window.TT_CONFIG.BASE_PATH}/Preorder/Api?do=getFCPsForCampaign&campaign_id=${this.selectedCampaign}`;
const response = await axios.get(url);
if (response.data.status === 'OK' && Array.isArray(response.data.result)) {
this.fcpMarkers = response.data.result.map(fcp => ({
lat: fcp.lat,
lng: fcp.lng,
options: {
icon: {
className: 'custom-div-icon',
html: `<div class="fcp-marker">${fcp.text}</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
},
tooltip: {
content: `FCP: ${fcp.text}`,
direction: 'top',
},
zIndexOffset: 500 // Keep FCPs slightly above other markers
},
}));
} else {
console.warn('Could not retrieve FCP data or data format is invalid.');
}
} catch (err) {
window.notify('warning', 'Laden der FCP-Daten fehlgeschlagen. Die Karte wird ohne sie angezeigt.');
// Do not re-throw; failing to load FCPs should not block the entire map.
}
},
@@ -86,13 +152,13 @@ Vue.component('PreorderRimoTypeMap', {
if (!groupedData[latLngKey]) {
groupedData[latLngKey] = {
...item,
wohneinheit_count: parseInt(item.wohneinheit_count, 10),
preorder_count: parseInt(item.preorder_count, 10),
wohneinheit_count: parseInt(item.wohneinheit_count, 10) || 0,
preorder_count: parseInt(item.preorder_count, 10) || 0,
original_items: [item],
};
} else {
groupedData[latLngKey].wohneinheit_count += parseInt(item.wohneinheit_count, 10);
groupedData[latLngKey].preorder_count += parseInt(item.preorder_count, 10);
groupedData[latLngKey].wohneinheit_count += parseInt(item.wohneinheit_count, 10) || 0;
groupedData[latLngKey].preorder_count += parseInt(item.preorder_count, 10) || 0;
groupedData[latLngKey].original_items.push(item);
}
});
@@ -111,7 +177,7 @@ Vue.component('PreorderRimoTypeMap', {
return {
lat: group.gps_lat,
lng: group.gps_long,
rimoType: rimoType, // Add normalized rimoType for filtering
rimoType: rimoType,
options: {
icon: {
className: `custom-div-icon marker-${rimoType}`,
@@ -137,7 +203,7 @@ Vue.component('PreorderRimoTypeMap', {
<strong>Wohn. gesamt:</strong> ${item.wohneinheit_count}<br>
<strong>Bestellungen:</strong> ${item.preorder_count}<br>
<strong>Koordinaten:</strong>
<a href="https://www.google.com/maps/search/?api=1&query=${item.gps_lat},${item.gps_long}" target="_blank" class="text-primary">
<a href="https://www.google.com/maps?q=${item.gps_lat},${item.gps_long}" target="_blank" class="text-primary">
<i class="fas fa-map-marker-alt mr-1"></i>Karte
</a>
<a href="https://thetool.xinon.at/AddressDB/View?id=${item.hausnummer_id}" target="_blank" class="text-primary ml-2">
@@ -145,7 +211,7 @@ Vue.component('PreorderRimoTypeMap', {
</a>
</div><hr class="my-1">`;
});
return content.slice(0, -16) + `</div>`; // Remove last <hr>
return content.slice(0, -16) + `</div>`;
},
},
};
@@ -154,6 +220,7 @@ Vue.component('PreorderRimoTypeMap', {
getNormalizedRimoType(type) {
const lowerType = (type || '').toLowerCase();
if (lowerType.includes('2/3')) return 'residential'; // Catches '2/3' as residential
if (lowerType.includes('greenfield')) return 'greenfield';
if (lowerType.includes('residential')) return 'residential';
if (lowerType.includes('multiple dwelling')) return 'multiple-dwelling';
@@ -163,15 +230,11 @@ Vue.component('PreorderRimoTypeMap', {
},
getMarkerIcon(rimoType) {
const icons = {
greenfield: { class: 'marker-greenfield', icon: 'fas fa-tree' },
residential: { class: 'marker-residential', icon: 'fas fa-home' },
company: { class: 'marker-company', icon: 'fas fa-building' },
'multiple-dwelling': { class: 'marker-multiple-dwelling', icon: 'fas fa-city' },
public: { class: 'marker-public', icon: 'fas fa-school' },
other: { class: 'marker-other', icon: 'fas fa-question-circle' }
const def = this.rimoTypeDefs[rimoType] || this.rimoTypeDefs.other;
return {
class: `marker-${rimoType}`,
icon: def.icon,
};
return icons[rimoType] || icons.other;
},
checkZoomLevel() {
@@ -187,10 +250,6 @@ Vue.component('PreorderRimoTypeMap', {
});
},
/**
* Toggles a filter on or off.
* @param {string} filterValue - The rimo_type to toggle.
*/
toggleFilter(filterValue) {
const index = this.activeFilters.indexOf(filterValue);
if (index > -1) {
@@ -200,14 +259,28 @@ Vue.component('PreorderRimoTypeMap', {
}
},
/**
* Checks if a filter is currently active.
* @param {string} filterValue - The rimo_type to check.
* @returns {boolean}
*/
isFilterActive(filterValue) {
return this.activeFilters.includes(filterValue);
}
},
/**
* Generates the style for a filter button based on its state (active/inactive).
*/
getFilterButtonStyle(filterValue) {
const color = this.rimoTypeDefs[filterValue]?.color || '#6c757d';
if (this.isFilterActive(filterValue)) {
return {
backgroundColor: color,
borderColor: color,
color: 'white',
};
}
return {
color: color,
backgroundColor: 'white',
borderColor: color,
};
},
},
template: `
<tt-card style="height: 75vh; position: relative; display: flex; flex-direction: column;">
@@ -219,7 +292,8 @@ Vue.component('PreorderRimoTypeMap', {
<button v-for="filter in filterOptions"
:key="filter.value"
@click="toggleFilter(filter.value)"
:class="['btn', 'btn-sm', isFilterActive(filter.value) ? 'btn-primary' : 'btn-outline-secondary']"
class="btn btn-sm"
:style="getFilterButtonStyle(filter.value)"
:title="filter.text">
<i :class="filter.icon"></i>
</button>