enhanced preorder map

This commit is contained in:
2025-09-18 12:55:26 +02:00
parent b69bfc3afe
commit f05ff28394
4 changed files with 384 additions and 57 deletions

View File

@@ -0,0 +1,48 @@
<?php
class GeocodingController extends mfBaseController {
protected function init() {
$this->needlogin = true;
$me = new User();
$me->loadMe();
$this->me = $me;
}
protected function autocompleteAction() {
$search = urlencode($this->request->q);
$url = TT_GEOCODING_API_URL . "?address={$search}&key=" . TT_GEOCODING_API_SECRET . "&region=at&language=de&components=country:AT";
if (!$response = @file_get_contents($url)) {
self::returnJson([]);
return;
}
$data = json_decode($response, true);
var_dump($data); // Debugging line, can be removed in production
if ($data['status'] !== 'OK' || empty($data['results'])) {
self::returnJson([]);
return;
}
$out = [];
foreach ($data['results'] as $entry) {
$hasHouseNumber = false;
foreach ($entry['address_components'] as $component) {
if (in_array('street_number', $component['types'])) {
$hasHouseNumber = true;
break;
}
}
$text = $entry['formatted_address'];
$value = "{$entry['geometry']['location']['lat']},{$entry['geometry']['location']['lng']}" . ($hasHouseNumber ? '' : ', area');
$out[$text] = ['value' => $value, 'text' => $text];
}
self::returnJson(array_values($out));
}
}

View File

@@ -21,6 +21,7 @@ Vue.component('PreorderRimoTypeMap', {
showFaultsModal: false,
logoControlAdded: false,
userIdToNameMap: new Map(),
editingFault: null, // Temporary state for the fault being edited
faultReasons: [
{ value: 'building_type', text: 'Gebäudetyp ist falsch' },
{ value: 'home_count', text: 'Anzahl der Wohneinheiten ist falsch' },
@@ -112,12 +113,12 @@ Vue.component('PreorderRimoTypeMap', {
}
const storedShowFcps = localStorage.getItem('rimoMapShowFcps');
this.showFcps = storedShowFcps !== null ? JSON.parse(storedShowFcps) : true;
window.updateMapFault = this.handleFaultUpdate.bind(this);
window.saveMapFaults = this.saveFaults.bind(this);
window.updateEditingFault = this.updateTempFault.bind(this);
window.saveEditingFault = this.saveFaults.bind(this);
},
beforeDestroy() {
delete window.updateMapFault;
delete window.saveMapFaults;
delete window.updateEditingFault;
delete window.saveEditingFault;
},
methods: {
addLogoToMap() {
@@ -263,7 +264,7 @@ Vue.component('PreorderRimoTypeMap', {
const rimoType = this.getNormalizedRimoType(group.rimo_type);
const markerIcon = this.getMarkerIcon(rimoType);
const fault = this.faults[group.hausnummer_id];
const hasFault = fault && !fault.done;
const hasFault = fault && !fault.done && (fault.reasons.length > 0 || (fault.other && fault.other.trim() !== ''));
let tooltipInnerClass = '';
if (rimoType !== 'greenfield' && group.wohneinheit_count > 0 && group.wohneinheit_count === group.preorder_count)
@@ -330,11 +331,11 @@ Vue.component('PreorderRimoTypeMap', {
const formInputs = this.faultReasons.map(reason => {
const isChecked = faultData.reasons.includes(reason.value);
const otherInput = (reason.value === 'other') ?
`<textarea class="fault-other-textarea ${isChecked ? '' : 'hidden'}" oninput="window.updateMapFault(${itemGroup.hausnummer_id}, 'other_text', this.value, false)">${faultData.other || ''}</textarea>` :
`<textarea class="fault-other-textarea ${isChecked ? '' : 'hidden'}" oninput="window.updateEditingFault('other_text', this.value)">${faultData.other || ''}</textarea>` :
'';
return `<label>
<input type="checkbox" onchange="window.updateMapFault(${itemGroup.hausnummer_id}, '${reason.value}', this.checked, false)" ${isChecked ? 'checked' : ''}>
<input type="checkbox" onchange="window.updateEditingFault('${reason.value}', this.checked)" ${isChecked ? 'checked' : ''}>
${reason.text}
</label>
${otherInput}`;
@@ -342,15 +343,20 @@ Vue.component('PreorderRimoTypeMap', {
return `<h6>Fehler melden/bearbeiten:</h6>
${formInputs}
<button class="btn btn-primary btn-sm mt-2" onclick="window.saveMapFaults()">Fehler Speichern</button>`;
<button class="btn btn-primary btn-sm mt-2" onclick="window.saveEditingFault()">Fehler Speichern</button>`;
},
generateBuildingPopupHtml(itemGroup) {
const faultData = this.faults[itemGroup.hausnummer_id] || { reasons: [], other: '' };
const currentFault = this.faults[itemGroup.hausnummer_id] || { reasons: [], other: '', done: false };
this.editingFault = {
hausnummerId: itemGroup.hausnummer_id,
data: JSON.parse(JSON.stringify(currentFault))
};
const detailsHtml = this._generateBuildingDetailsHtml(itemGroup);
const faultFormHtml = this._generateBuildingFaultFormHtml(itemGroup, faultData);
const faultDisplayHtml = faultData.done ?
`<div class="alert alert-success"><i class="fas fa-check-circle"></i> Dieser Fehler wurde von <strong>${this.userIdToNameMap.get(String(faultData.done_by)) || `User #${faultData.done_by}`}</strong> am ${new Date(faultData.done_at).toLocaleDateString()} als erledigt markiert.</div>` :
this._generateBuildingFaultDisplayHtml(faultData);
const faultFormHtml = this._generateBuildingFaultFormHtml(itemGroup, this.editingFault.data);
const faultDisplayHtml = this.editingFault.data.done ?
`<div class="alert alert-success"><i class="fas fa-check-circle"></i> Dieser Fehler wurde von <strong>${this.userIdToNameMap.get(String(this.editingFault.data.done_by)) || `User #${this.editingFault.data.done_by}`}</strong> am ${new Date(this.editingFault.data.done_at).toLocaleDateString()} als erledigt markiert.</div>` :
this._generateBuildingFaultDisplayHtml(this.editingFault.data);
return `<div class="building-popup-content">
${detailsHtml}
@@ -359,36 +365,49 @@ Vue.component('PreorderRimoTypeMap', {
<div class="fault-reporting-form">${faultFormHtml}</div>
</div>`;
},
handleFaultUpdate(hausnummerId, reason, value, from_mark_as_done = false) {
if (!this.faults[hausnummerId])
this.$set(this.faults, hausnummerId, { reasons: [], other: '', done: false });
else if (this.faults[hausnummerId].done && !from_mark_as_done)
this.$set(this.faults, hausnummerId, { reasons: [], other: '', done: false });
updateTempFault(reason, value) {
if (!this.editingFault) return;
if (this.editingFault.data.done) {
this.editingFault.data.done = false;
this.editingFault.data.done_by = null;
this.editingFault.data.done_at = null;
}
const fault = this.faults[hausnummerId];
const fault = this.editingFault.data;
if (reason === 'other_text') {
fault.other = value;
} else if (reason) {
} else { // It's a checkbox change
const index = fault.reasons.indexOf(reason);
if (value && index === -1) fault.reasons.push(reason);
else if (!value && index > -1) fault.reasons.splice(index, 1);
const popup = this.$refs.ttMap?.map?._popup;
if (popup?.isOpen()) {
const otherTextarea = popup.getElement().querySelector('.fault-other-textarea');
if (otherTextarea) otherTextarea.classList.toggle('hidden', !fault.reasons.includes('other'));
if (value && index === -1) { // checked
fault.reasons.push(reason);
} else if (!value && index > -1) { // unchecked
fault.reasons.splice(index, 1);
}
}
const markerInstance = this.$refs.ttMap.markerLayer.getLayers().find(m => m.tt_hausnummerId == hausnummerId);
if (markerInstance?.getElement) {
const hasOpenFault = fault && !fault.done && (fault.reasons.length > 0 || fault.other);
markerInstance.getElement().classList.toggle('marker-has-fault', hasOpenFault);
if (reason === 'other') {
const popup = this.$refs.ttMap?.map?._popup;
if (popup?.isOpen()) {
const otherTextarea = popup.getElement().querySelector('.fault-other-textarea');
if (otherTextarea) {
const hasOther = fault.reasons.includes('other');
otherTextarea.classList.toggle('hidden', !hasOther);
if (!hasOther) {
otherTextarea.value = '';
fault.other = '';
}
}
}
}
}
},
async saveFaults() {
if (!this.editingFault) return;
const { hausnummerId, data } = this.editingFault;
this.$set(this.faults, hausnummerId, data);
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Preorder/RimoTypeMapSaveFaults`, {
campaignId: this.selectedCampaign,
faults: this.faults
@@ -396,9 +415,11 @@ Vue.component('PreorderRimoTypeMap', {
if (response.data.success) {
window.notify('success', 'Fehlerbericht gespeichert.');
this.$refs.ttMap?.map.closePopup();
this.mapMarkers = this.processData(this.rawRimoData);
} else {
window.notify('error', 'Fehlerbericht konnte nicht gespeichert werden.');
}
this.editingFault = null;
},
async markFaultAsDone(hausnummerId) {
if (!hausnummerId || !this.faults[hausnummerId]) return;
@@ -411,7 +432,6 @@ Vue.component('PreorderRimoTypeMap', {
});
await this.saveFaults();
this.handleFaultUpdate(hausnummerId, null, null, true);
},
zoomToFaultMarker(hausnummerId) {
const map = this.$refs.ttMap?.map;
@@ -475,28 +495,28 @@ Vue.component('PreorderRimoTypeMap', {
></tt-select>
</div>
<div class="map-filter-container" v-if="selectedCampaign">
<h6 class="mr-2 font-weight-bold align-self-center">Filter:</h6>
<button v-for="filter in filterOptions"
:key="filter.value"
@click="toggleFilter(filter.value)"
class="btn btn-sm"
:style="getFilterButtonStyle(filter.value)"
:title="filter.text">
<i :class="filter.icon"></i>
</button>
<div class="filter-separator"></div>
<button @click="showOnlyFaults = !showOnlyFaults"
class="btn btn-sm"
:class="showOnlyFaults ? 'btn-danger' : 'btn-outline-danger'"
title="Nur Gebäude mit Fehlern anzeigen">
<i class="fas fa-exclamation-triangle"></i>
</button>
<button @click="toggleShowFcps"
class="btn btn-sm"
:class="showFcps ? 'btn-info' : 'btn-outline-info'"
title="FCPs anzeigen/ausblenden">
<i class="fas fa-broadcast-tower"></i>
</button>
<h6 class="mr-2 font-weight-bold align-self-center">Filter:</h6>
<button v-for="filter in filterOptions"
:key="filter.value"
@click="toggleFilter(filter.value)"
class="btn btn-sm"
:style="getFilterButtonStyle(filter.value)"
:title="filter.text">
<i :class="filter.icon"></i>
</button>
<div class="filter-separator"></div>
<button @click="showOnlyFaults = !showOnlyFaults"
class="btn btn-sm"
:class="showOnlyFaults ? 'btn-danger' : 'btn-outline-danger'"
title="Nur Gebäude mit Fehlern anzeigen">
<i class="fas fa-exclamation-triangle"></i>
</button>
<button @click="toggleShowFcps"
class="btn btn-sm"
:class="showFcps ? 'btn-info' : 'btn-outline-info'"
title="FCPs anzeigen/ausblenden">
<i class="fas fa-broadcast-tower"></i>
</button>
</div>
</template>
<template v-slot:legend>

View File

@@ -29,7 +29,7 @@
.tt-map-top-controls {
position: absolute;
top: 10px;
left: 55px; /* Moved to avoid overlapping with zoom controls */
left: 10px;
z-index: 401;
}
@@ -44,7 +44,7 @@
/* Container for built-in map buttons (zoom, layer toggle) */
.tt-map-builtin-controls {
position: absolute;
top: 10px;
top: 60px; /* Pushed down to make space for the search bar */
right: 10px;
z-index: 401;
display: flex;
@@ -74,4 +74,142 @@
.popup-loader {
text-align: center;
padding: 10px;
}
/* --- NEW MAP SEARCH STYLES --- */
.tt-map-search-wrapper {
position: absolute;
top: 10px;
right: 10px;
z-index: 402;
width: 320px;
max-width: calc(100% - 20px);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.tt-map-search-input-container {
position: relative;
display: flex;
align-items: center;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0,0,0,0.1);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
padding: 0.25rem;
height: 42px;
}
.tt-map-search-input {
width: 100%;
height: 100%;
padding: 0 30px 0 32px;
border: none;
background-color: transparent;
font-size: 14px;
outline: none;
}
.tt-map-search-input-container .search-icon {
position: absolute;
left: 12px;
color: #888;
pointer-events: none;
}
.tt-map-search-input-container .clear-icon {
position: absolute;
right: 8px;
background: none;
border: none;
cursor: pointer;
color: #aaa;
font-size: 16px;
padding: 5px;
}
.tt-map-search-input-container .clear-icon:hover {
color: #333;
}
.tt-map-search-results {
list-style: none;
padding: 0;
margin: 8px 0 0 0;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
}
.tt-map-search-results .result-item,
.tt-map-search-results .result-item-info {
padding: 10px 15px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: background-color 0.15s;
font-size: 14px;
}
.tt-map-search-results .result-item:hover {
background-color: #f5f5f5;
}
.tt-map-search-results .result-item:not(:last-child) {
border-bottom: 1px solid #eee;
}
.tt-map-search-results .result-item i {
color: #6c757d;
}
.tt-map-search-results .result-item-info {
color: #6c757d;
cursor: default;
}
/* Fade animation for the results list */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
/* --- NEW CUSTOM MARKER STYLE --- */
.custom-map-marker {
background-color: rgba(0, 123, 255, 0.9);
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.9);
display: flex;
justify-content: center;
align-items: center;
animation: pulse 1.5s infinite;
}
.custom-map-marker i {
color: white;
font-size: 12px;
}
@keyframes pulse {
0% {
transform: scale(0.9);
box-shadow: 0 0 0 0 rgba(0, 123, 255, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba(0, 123, 255, 0);
}
100% {
transform: scale(0.9);
box-shadow: 0 0 0 0 rgba(0, 123, 255, 0);
}
}

View File

@@ -27,6 +27,12 @@ Vue.component('tt-map', {
internalLoading: true,
scriptsLoaded: false,
showSettings: false,
searchQuery: '',
searchResults: [],
isSearchLoading: false,
showSearchResults: false,
searchDebounce: null,
selectedMarker: null, // To hold the temporary marker
};
},
computed: {
@@ -248,6 +254,80 @@ Vue.component('tt-map', {
if (this.map) {
this.map.invalidateSize();
}
},
searchLocations() {
if (this.searchDebounce) clearTimeout(this.searchDebounce);
if (this.searchQuery.length < 3) {
this.searchResults = [];
this.showSearchResults = true;
return;
}
this.isSearchLoading = true;
this.searchDebounce = setTimeout(async () => {
try {
const response = await axios.get('/Geocoding/autocomplete', {
params: { q: this.searchQuery }
});
this.searchResults = response.data;
} catch (error) {
console.error("Geocoding search failed:", error);
this.searchResults = [];
} finally {
this.isSearchLoading = false;
}
}, 350);
},
selectLocation(location) {
this.searchQuery = location.text;
this.showSearchResults = false;
const [lat, lng] = location.value.split(',').map(Number);
if (this.map && !isNaN(lat) && !isNaN(lng)) {
this.map.flyTo([lat, lng], 17, { duration: 0.5 });
if (this.selectedMarker) {
this.selectedMarker.remove();
}
const customIcon = L.divIcon({
html: `<div class="custom-map-marker"><i class="fas fa-map-marker-alt"></i></div>`,
className: '', // Important to keep it empty
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -14]
});
this.selectedMarker = L.marker([lat, lng], { icon: customIcon }).addTo(this.map)
.bindPopup(`<b>${location.text}</b>`)
.openPopup();
this.searchQuery = '';
this.searchResults = [];
setTimeout(() => {
if (this.selectedMarker) {
this.selectedMarker.remove();
this.selectedMarker = null;
}
}, 10000);
}
},
clearSearch() {
this.searchQuery = '';
this.searchResults = [];
this.showSearchResults = false;
if (this.selectedMarker) {
this.selectedMarker.remove();
this.selectedMarker = null;
}
},
handleSearchFocus() {
this.showSearchResults = true;
},
handleSearchBlur() {
setTimeout(() => {
this.showSearchResults = false;
}, 200);
}
},
watch: {
@@ -273,6 +353,47 @@ Vue.component('tt-map', {
</div>
<div ref="mapContainer" class="tt-map-container" :style="{ visibility: internalLoading ? 'hidden' : 'visible' }"></div>
<div class="tt-map-search-wrapper">
<div class="tt-map-search-input-container">
<i class="fas fa-search search-icon"></i>
<input
type="text"
class="tt-map-search-input"
placeholder="Adresse suchen..."
v-model="searchQuery"
@input="searchLocations"
@focus="handleSearchFocus"
@blur="handleSearchBlur"
autocomplete="off"
/>
<button v-if="searchQuery" @click="clearSearch" class="clear-icon">
<i class="fas fa-times"></i>
</button>
</div>
<transition name="fade">
<ul v-if="showSearchResults" class="tt-map-search-results">
<li v-if="isSearchLoading" class="result-item-info">
<i class="fas fa-spinner fa-spin"></i> Suche läuft...
</li>
<li v-else-if="searchQuery.length < 3" class="result-item-info">
Bitte mind. 3 Zeichen eingeben
</li>
<li v-else-if="searchResults.length === 0" class="result-item-info">
Keine Ergebnisse gefunden
</li>
<li
v-for="result in searchResults"
:key="result.value"
class="result-item"
@mousedown="selectLocation(result)"
>
<i class="fas fa-map-marker-alt"></i>
<span>{{ result.text }}</span>
</li>
</ul>
</transition>
</div>
<div class="tt-map-top-controls">
<slot name="tools"></slot>
</div>