Add context menu for missing buildings and update fault handling

This commit is contained in:
2025-11-04 09:45:26 +01:00
parent 5c94fe3205
commit 98be5e1614
4 changed files with 362 additions and 76 deletions

View File

@@ -1,50 +1,42 @@
# Use Debian Bookworm as base image
FROM debian:bookworm
# Use Debian 13 “Trixie” as base image
FROM debian:trixie
# Install wkhtmltopdf
RUN apt update
RUN apt install wget libfontenc1 xfonts-75dpi xfonts-base xfonts-encodings xfonts-utils openssl build-essential libssl-dev libxrender-dev git-core libx11-dev libxext-dev libfontconfig1-dev libfreetype6-dev fontconfig -y
RUN wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.stretch_amd64.deb
RUN dpkg --force-all -i wkhtmltox_0.12.6-1.stretch_amd64.deb
RUN wget https://www.mytaxexpress.com/download/libssl1.1_1.1.1f-1ubuntu2.17_amd64.deb
RUN dpkg -i libssl1.1_1.1.1f-1ubuntu2.17_amd64.deb
RUN wget https://archive.debian.org/debian/pool/main/libj/libjpeg8/libjpeg8_8b-1_amd64.deb
RUN dpkg -i libjpeg8_8b-1_amd64.deb
# Install apache2 and PHP and PHP modules
# Install ALL native packages from Debian 13 first
RUN apt update && \
apt install -y poppler-utils apache2 curl cron unzip php8.2 php8.2-imap php8.2-curl php8.2-cli php8.2-mysqli php8.2-gd php8.2-zip php8.2-dom php8.2-mbstring && \
apt install -y \
# wkhtmltopdf prerequisites
wget libfontenc1 xfonts-75dpi xfonts-base xfonts-encodings xfonts-utils openssl build-essential libssl-dev libxrender-dev git-core libx11-dev libxext-dev libfontconfig1-dev libfreetype6-dev fontconfig \
\
# Apache + PHP + Utils
poppler-utils apache2 curl cron unzip \
php8.4 php8.4-curl php8.4-cli php8.4-mysqli php8.4-gd php8.4-zip php8.4-dom php8.4-mbstring && \
\
# Install Composer
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer && \
apt clean && \
rm -rf /var/lib/apt/lists/*
\
# Clean up apt cache
apt clean && rm -rf /var/lib/apt/lists/*
# Enable PHP in Apache2
RUN a2enmod php8.2
RUN a2enmod rewrite
# --- Now, install the old/insecure libraries for wkhtmltopdf ---
# Composer install
RUN wget https://www.mytaxexpress.com/download/libssl1.1_1.1.1f-1ubuntu2.17_amd64.deb && \
dpkg -i libssl1.1_1.1.1f-1ubuntu2.17_amd64.deb
RUN wget https://archive.debian.org/debian/pool/main/libj/libjpeg8/libjpeg8_8b-1_amd64.deb && \
dpkg -i libjpeg8_8b-1_amd64.deb
# Finally, install wkhtmltopdf itself, forcing it over the broken dependencies
RUN wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.stretch_amd64.deb && \
dpkg --force-all -i wkhtmltox_0.12.6-1.stretch_amd64.deb
# Enable PHP in Apache2 and enable rewrite
RUN a2enmod php8.4 \
&& a2enmod rewrite
# Set working directory and copy composer.json, then run composer install
WORKDIR /var/www/html
COPY ../../composer.json ./
RUN composer install --no-interaction
COPY ./docker/php/clean_logs.sh /root/clean_logs.sh
RUN chmod +x /root/clean_logs.sh
# Add cron job for log cleanup
RUN echo "* * * * * /root/clean_old_logs.sh" > /etc/cron.d/clean_old_logs && \
chmod 0644 /etc/cron.d/clean_old_logs && \
crontab /etc/cron.d/clean_old_logs
# Start Apache in the foreground
CMD ["apachectl", "-D", "FOREGROUND"]
# Install XDEBUG
# apt install -y php8.2-xdebug
#
# cat <<'EOF' > /etc/php/8.2/apache2/conf.d/99-xdebug-custom.ini
#[xdebug]
#xdebug.mode=profile
#xdebug.start_with_request=trigger
#xdebug.output_dir="/tmp/xdebug_profiles"
#EOF
# Expose port 80 and start Apache in foreground
CMD ["apachectl", "-D", "FOREGROUND"]

View File

@@ -215,6 +215,14 @@ div.leaflet-marker-icon.custom-div-icon {
animation: pulse-red 2s infinite;
}
/* New style for missing building markers */
.marker-missing-building .rimo-marker {
border-style: dashed;
border-width: 3px;
box-shadow: 0 0 8px rgba(220, 53, 69, 0.8);
}
@keyframes pulse-red {
0% {
box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7);
@@ -348,4 +356,4 @@ div.leaflet-marker-icon.custom-div-icon {
.marker-multiple-dwelling { background-color: #6f42c1; }
.marker-public { background-color: #17a2b8; }
.marker-other { background-color: #bf2d69; }
.marker-not-to-connect { background-color: #6c757d !important; }
.marker-not-to-connect { background-color: #6c757d !important; }

View File

@@ -3,6 +3,7 @@ Vue.component('PreorderRimoTypeMap', {
rawRimoData: [],
mapMarkers: [],
fcpMarkers: [],
missingBuildingMarkers: [], // For manually added faults
faults: {},
isLoading: false,
window,
@@ -19,12 +20,15 @@ Vue.component('PreorderRimoTypeMap', {
showOnlyFaults: false,
showFcps: true,
showFaultsModal: false,
showMissingBuildingModal: false, // For new context menu modal
missingBuildingData: null, // For new context menu modal
userIdToNameMap: new Map(),
editingFault: null,
faultReasons: [
{value: 'building_type', text: 'Gebäudetyp ist falsch'},
{value: 'home_count', text: 'Anzahl der Wohneinheiten ist falsch'},
{value: 'not_existent', text: 'Gebäude existiert nicht'},
{value: 'missing_building', text: 'Gebäude fehlt (manuell markiert)'}, // New reason
{value: 'other', text: 'Sonstiges/Bemerkung'}
],
rimoTypeDefs: {
@@ -44,6 +48,10 @@ Vue.component('PreorderRimoTypeMap', {
filterOptions() {
return Object.entries(this.rimoTypeDefs).map(([value, defs]) => ({value, ...defs}));
},
// Options for the new "missing building" modal
rimoTypeOptionsForMissing() {
return Object.entries(this.rimoTypeDefs).map(([value, defs]) => ({value, text: defs.text}));
},
filteredMapMarkers() {
let rimoMarkers = this.mapMarkers;
@@ -52,24 +60,48 @@ Vue.component('PreorderRimoTypeMap', {
const fault = this.faults[marker.hausnummerId];
return fault && !fault.done;
});
// Also filter missing building markers if showOnlyFaults is true
this.missingBuildingMarkers = this.missingBuildingMarkers.filter(marker => {
const fault = this.faults[marker.hausnummerId];
return fault && !fault.done;
});
}
if (this.activeFilters.length > 0) {
rimoMarkers = rimoMarkers.filter(marker => this.activeFilters.includes(marker.rimoType));
// We don't filter missingBuildingMarkers by rimoType unless we want to
this.missingBuildingMarkers = this.missingBuildingMarkers.filter(marker => {
const fault = this.faults[marker.hausnummerId];
return fault && this.activeFilters.includes(fault.rimo_type);
});
}
return this.showFcps ? [...rimoMarkers, ...this.fcpMarkers] : rimoMarkers;
const allMarkers = [...rimoMarkers, ...this.missingBuildingMarkers];
return this.showFcps ? [...allMarkers, ...this.fcpMarkers] : allMarkers;
},
faultsForModal() {
if (!this.rawRimoData.length) return [];
if (!this.rawRimoData && !this.faults) return [];
return Object.entries(this.faults).map(([hausnummerId, faultData]) => {
const rimoItem = this.rawRimoData.find(item => item.hausnummer_id == hausnummerId);
if (!rimoItem) return null;
let rimoItem = null;
let address = '';
let rimo_id = '';
if (hausnummerId.startsWith('missing-')) {
const typeDef = this.rimoTypeDefs[faultData.rimo_type] || this.rimoTypeDefs.other;
address = `Fehlendes Gebäude (${typeDef.text}) bei ${faultData.lat.toFixed(5)}, ${faultData.lng.toFixed(5)}`;
rimo_id = 'N/A (Fehlend)';
} else {
rimoItem = this.rawRimoData.find(item => item.hausnummer_id == hausnummerId);
if (!rimoItem) return null; // Don't show faults for buildings not in the current dataset
address = `${rimoItem.strasse_name} ${rimoItem.hausnummer}, ${rimoItem.plz_name} ${rimoItem.ortschaft_name}`;
rimo_id = rimoItem.rimo_id;
}
return {
...faultData,
hausnummerId,
rimo_id: rimoItem.rimo_id,
address: `${rimoItem.strasse_name} ${rimoItem.hausnummer}, ${rimoItem.plz_name} ${rimoItem.ortschaft_name}`,
rimo_id,
address,
translated_reasons: faultData.reasons.map(r => this.faultReasons.find(fr => fr.value === r)?.text || r),
done_by_user: this.userIdToNameMap.get(String(faultData.done_by)) || `User #${faultData.done_by}`
};
@@ -87,7 +119,7 @@ Vue.component('PreorderRimoTypeMap', {
this.fetchAllMapData();
} else {
localStorage.removeItem('rimoMapSelectedCampaign');
this.mapMarkers = []; this.fcpMarkers = []; this.faults = {}; this.activeFilters = [];
this.mapMarkers = []; this.fcpMarkers = []; this.faults = {}; this.activeFilters = []; this.missingBuildingMarkers = [];
}
},
showFcps(newVal) {
@@ -114,28 +146,45 @@ Vue.component('PreorderRimoTypeMap', {
}
const storedShowFcps = localStorage.getItem('rimoMapShowFcps');
this.showFcps = storedShowFcps !== null ? JSON.parse(storedShowFcps) : true;
// Register window functions for popup buttons
window.updateEditingFault = this.updateTempFault.bind(this);
window.saveEditingFault = this.saveFaults.bind(this);
window.saveEditingFault = this.saveEditingFault.bind(this);
// --- FIX: Renamed function and window property to avoid reference errors ---
window.markFaultDonePopup = this.markFaultDonePopup.bind(this);
},
beforeDestroy() {
// Unregister window functions
delete window.updateEditingFault;
delete window.saveEditingFault;
// --- FIX: Use matching name for deletion ---
delete window.markFaultDonePopup;
},
methods: {
async fetchAllMapData() {
if (!this.selectedCampaign) return;
this.isLoading = true;
this.mapMarkers = []; this.fcpMarkers = []; this.faults = {}; this.activeFilters = [];
this.mapMarkers = []; this.fcpMarkers = []; this.faults = {}; this.activeFilters = []; this.missingBuildingMarkers = [];
try {
await this.fetchFaultData();
await this.fetchFaultData(); // This will now also populate missingBuildingMarkers
await Promise.all([this.fetchRimoData(), this.fetchFCPData()]);
} finally {
this.isLoading = false;
}
},
async fetchFaultData() {
this.missingBuildingMarkers = []; // Reset missing markers
const res = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Preorder/RimoTypeMapGetFaults`, {params: {preordercampaign_id: this.selectedCampaign}});
if (res.data.success) this.faults = res.data.faults && typeof res.data.faults === 'object' && !Array.isArray(res.data.faults) ? res.data.faults : {};
if (res.data.success) {
this.faults = res.data.faults && typeof res.data.faults === 'object' && !Array.isArray(res.data.faults) ? res.data.faults : {};
// Process missing building faults into markers
Object.entries(this.faults).forEach(([faultId, faultData]) => {
if (faultId.startsWith('missing-')) {
this.addMissingBuildingMarker(faultId, faultData);
}
});
}
},
async fetchRimoData() {
const res = await axios.post(this.fetchUrl, {campaignId: this.selectedCampaign});
@@ -224,24 +273,62 @@ Vue.component('PreorderRimoTypeMap', {
const otherText = faultData.other ? `<li>Sonstiges: ${faultData.other}</li>` : '';
return `<div class="fault-indicator-popup"><strong><i class="fas fa-exclamation-triangle"></i> Gemeldeter Fehler:</strong><ul>${reasonsList}${otherText}</ul></div>`;
},
_generateBuildingFaultFormHtml(itemGroup, faultData) {
_generateBuildingFaultFormHtml(itemGroup, faultData, isExistingFault) {
const formInputs = this.faultReasons.map(reason => {
const isChecked = faultData.reasons.includes(reason.value);
const isDisabled = reason.value === 'missing_building'; // Disable 'missing_building' checkbox
const otherInput = (reason.value === 'other') ? `<textarea class="fault-other-textarea ${isChecked ? '' : 'hidden'}" oninput="window.updateEditingFault('other_text', this.value)">${faultData.other || ''}</textarea>` : '';
return `<label><input type="checkbox" onchange="window.updateEditingFault('${reason.value}', this.checked)" ${isChecked ? 'checked' : ''}> ${reason.text}</label>${otherInput}`;
return `<label><input type="checkbox" onchange="window.updateEditingFault('${reason.value}', this.checked)" ${isChecked ? 'checked' : ''} ${isDisabled ? 'disabled' : ''}> ${reason.text}</label>${otherInput}`;
}).join('');
return `<h6>Fehler melden/bearbeiten:</h6>${formInputs}<button class="btn btn-primary btn-sm mt-2" onclick="window.saveEditingFault()">Fehler Speichern</button>`;
// --- FIX: Updated logic to only show button if fault exists and is not done ---
const doneButton = isExistingFault && !faultData.done
? `<button class="btn btn-success btn-sm mt-2 ml-2" onclick="window.markFaultDonePopup()">Als erledigt markieren</button>`
: '';
return `<h6>Fehler melden/bearbeiten:</h6>${formInputs}<button class="btn btn-primary btn-sm mt-2" onclick="window.saveEditingFault()">Änderungen Speichern</button>${doneButton}`;
},
generateBuildingPopupHtml(itemGroup) {
// --- FIX: Check if the fault actually exists ---
const isExistingFault = !!this.faults[itemGroup.hausnummer_id];
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, 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);
// --- FIX: Pass the isExistingFault flag ---
const faultFormHtml = this._generateBuildingFaultFormHtml(itemGroup, this.editingFault.data, isExistingFault);
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}${faultDisplayHtml}<hr class="my-2"><div class="fault-reporting-form">${faultFormHtml}</div></div>`;
},
// New method for "missing building" popups
generateMissingBuildingPopupHtml(faultId) {
const currentFault = this.faults[faultId] || {reasons: [], other: '', done: false};
const isExistingFault = !!this.faults[faultId];
this.editingFault = {hausnummerId: faultId, data: JSON.parse(JSON.stringify(currentFault))};
const typeDef = this.rimoTypeDefs[currentFault.rimo_type] || this.rimoTypeDefs.other;
const detailsHtml = `
<div class="mb-2">
<h5 class="mb-2 mt-1"><i class="fas fa-map-pin mr-2"></i>Fehlendes Gebäude</h5>
<strong>Gemeldeter Typ:</strong> ${typeDef.text}<br>
<strong>Koordinaten:</strong> ${currentFault.lat.toFixed(6)}, ${currentFault.lng.toFixed(6)}<br>
<strong>Links:</strong>
<a href="https://www.google.com/maps?q=${currentFault.lat},${currentFault.lng}" target="_blank" class="text-primary"><i class="fas fa-map-marker-alt mr-1"></i>Karte</a>
</div>`;
// --- FIX: Pass the isExistingFault flag ---
const faultFormHtml = this._generateBuildingFaultFormHtml({}, this.editingFault.data, isExistingFault); // Pass empty itemGroup
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}${faultDisplayHtml}<hr class="my-2"><div class="fault-reporting-form">${faultFormHtml}</div></div>`; },
updateTempFault(reason, value) {
if (!this.editingFault) return;
// If user interacts with a "done" fault, mark it as "not done" again
if (this.editingFault.data.done) {
this.editingFault.data.done = false; this.editingFault.data.done_by = null; this.editingFault.data.done_at = null;
}
@@ -251,6 +338,8 @@ Vue.component('PreorderRimoTypeMap', {
const index = fault.reasons.indexOf(reason);
if (value && index === -1) fault.reasons.push(reason);
else if (!value && index > -1) fault.reasons.splice(index, 1);
// Toggle visibility of 'other' textarea
if (reason === 'other') {
const popup = this.$refs.ttMap?.map?._popup;
if (popup?.isOpen()) {
@@ -264,54 +353,202 @@ Vue.component('PreorderRimoTypeMap', {
}
}
},
async saveFaults(hausnummerIdToUpdate) {
// --- FIX: Renamed this method ---
async markFaultDonePopup() {
if (!this.editingFault) return;
const hausnummerId = this.editingFault.hausnummerId;
const faultData = {
...this.editingFault.data,
done: true,
done_by: window.TT_CONFIG.USER_ID,
done_at: new Date().toISOString()
};
this.$set(this.faults, hausnummerId, faultData);
this.editingFault.data = faultData; // Update local state for popup refresh
await this.saveFaults(hausnummerId, true); // Save and stay in place
},
// Modified saveFaults to handle "stayInPlace" and "missing" buildings
async saveFaults(hausnummerIdToUpdate, stayInPlace = false) {
const hausnummerId = hausnummerIdToUpdate || this.editingFault?.hausnummerId;
if (!hausnummerId) { if (this.editingFault) this.editingFault = null; return; }
if (this.editingFault && this.editingFault.hausnummerId === hausnummerId) this.$set(this.faults, hausnummerId, this.editingFault.data);
// Store current view to prevent map from moving
const center = this.$refs.ttMap?.map.getCenter();
const zoom = this.$refs.ttMap?.map.getZoom();
if (this.editingFault && this.editingFault.hausnummerId === hausnummerId) {
this.$set(this.faults, hausnummerId, this.editingFault.data);
}
const res = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Preorder/RimoTypeMapSaveFaults`, {campaignId: this.selectedCampaign, faults: this.faults});
if (res.data.success) {
window.notify('success', 'Fehlerbericht gespeichert.');
this.$refs.ttMap?.map.closePopup();
// Refetch fault data to be in sync (backend might have changed something)
await this.fetchFaultData();
// Update marker icon in place
const mapComponent = this.$refs.ttMap;
if (mapComponent && mapComponent.markerLayer) {
mapComponent.markerLayer.eachLayer(marker => {
if (marker.tt_hausnummerId == hausnummerId) {
const rimoItem = this.rawRimoData.find(item => item.hausnummer_id == hausnummerId);
if (rimoItem) {
const isNot2Connect = rimoItem.rimo_op_state === 'Not2Connect';
const rimoType = this.getNormalizedRimoType(rimoItem.rimo_type);
const fault = this.faults[hausnummerId];
const hasFault = fault && !fault.done;
const fault = this.faults[hausnummerId];
const hasFault = fault && !fault.done;
if (marker.options.isMissingBuilding) {
// Logic for missing building marker
const rimoType = fault.rimo_type || 'other';
const markerIconDef = this.getMarkerIcon(rimoType);
const newIcon = L.divIcon({
className: `custom-div-icon marker-${rimoType} ${hasFault ? 'marker-has-fault' : ''}`,
html: `<div class="rimo-marker ${markerIconDef.class} ${isNot2Connect ? 'marker-not-to-connect' : ''}"><i class="${markerIconDef.icon} rimo-icon"></i></div>`,
className: `custom-div-icon marker-${rimoType} ${hasFault ? 'marker-has-fault' : ''} marker-missing-building`,
html: `<div class="rimo-marker ${markerIconDef.class}"><i class="${markerIconDef.icon} rimo-icon"></i></div>`,
iconSize: [30, 30],
iconAnchor: [15, 30]
});
marker.setIcon(newIcon);
} else {
// Logic for existing building marker
const rimoItem = this.rawRimoData.find(item => item.hausnummer_id == hausnummerId);
if (rimoItem) {
const isNot2Connect = rimoItem.rimo_op_state === 'Not2Connect';
const rimoType = this.getNormalizedRimoType(rimoItem.rimo_type);
const markerIconDef = this.getMarkerIcon(rimoType);
const newIcon = L.divIcon({
className: `custom-div-icon marker-${rimoType} ${hasFault ? 'marker-has-fault' : ''}`,
html: `<div class="rimo-marker ${markerIconDef.class} ${isNot2Connect ? 'marker-not-to-connect' : ''}"><i class="${markerIconDef.icon} rimo-icon"></i></div>`,
iconSize: [30, 30],
iconAnchor: [15, 30]
});
marker.setIcon(newIcon);
}
}
}
});
}
} else window.notify('error', 'Fehlerbericht konnte nicht gespeichert werden.');
this.editingFault = null;
// Handle popup state
if (stayInPlace) {
const markerInstance = mapComponent?.markerLayer?.getLayers().find(m => m.tt_hausnummerId == hausnummerId);
if (markerInstance && mapComponent.map._popup && mapComponent.map._popup.isOpen() && mapComponent.map._popup._source === markerInstance) {
let newPopupContent;
if(markerInstance.options.isMissingBuilding) {
newPopupContent = await this.generateMissingBuildingPopupHtml(hausnummerId);
} else {
const rimoItem = this.rawRimoData.find(item => item.hausnummer_id == hausnummerId);
if(rimoItem) newPopupContent = await this.generateBuildingPopupHtml(rimoItem);
}
if (newPopupContent) {
markerInstance.setPopupContent(newPopupContent); // Refresh popup content
} else {
mapComponent.map.closePopup(); // Close if we can't refresh
}
} else if (!markerInstance) {
mapComponent.map.closePopup(); // Close if marker somehow disappeared (e.g. filtered out)
}
} else {
this.$refs.ttMap?.map.closePopup();
}
// Restore view
if (center && zoom) {
this.$refs.ttMap?.map.setView(center, zoom, { animate: false, noMoveStart: true });
}
} else {
window.notify('error', 'Fehlerbericht konnte nicht gespeichert werden.');
}
if(!stayInPlace) {
this.editingFault = null;
}
},
async markFaultAsDone(hausnummerId) {
if (!hausnummerId || !this.faults[hausnummerId]) return;
this.$set(this.faults, hausnummerId, {...this.faults[hausnummerId], done: true, done_by: window.TT_CONFIG.USER_ID, done_at: new Date().toISOString()});
await this.saveFaults(hausnummerId);
await this.saveFaults(hausnummerId, false); // Save, don't stay in place (closes modal)
},
zoomToFaultMarker(hausnummerId) {
const map = this.$refs.ttMap?.map;
const markerLayer = this.$refs.ttMap?.markerLayer;
if (!map || !markerLayer) return window.notify('error', 'Kartenkomponente ist nicht bereit.');
// Find marker (works for normal and missing building markers)
const markerInstance = markerLayer.getLayers().find(m => m.tt_hausnummerId == hausnummerId);
if (!markerInstance) return window.notify('warning', 'Marker konnte nicht auf der Karte gefunden werden.');
this.showFaultsModal = false;
map.flyTo(markerInstance.getLatLng(), 19, {duration: 1});
setTimeout(() => markerLayer.zoomToShowLayer(markerInstance, () => markerInstance.openPopup()), 1100);
},
// New method to handle right-click context menu
handleMapContextMenu(e) {
if (!this.selectedCampaign) return; // Don't allow if no campaign is selected
e.originalEvent.preventDefault();
this.missingBuildingData = { lat: e.latlng.lat, lng: e.latlng.lng, rimo_type: null };
this.showMissingBuildingModal = true;
},
// New method to save the missing building
async saveMissingBuilding() {
if (!this.missingBuildingData || !this.missingBuildingData.rimo_type) {
window.notify('warning', 'Bitte einen RIMO-Typ auswählen.');
return;
}
const newFaultId = 'missing-' + Math.random().toString(36).substr(2, 9);
const newFaultData = {
reasons: ['missing_building'],
other: 'Vom Benutzer als fehlend markiert.',
done: false,
created_by: window.TT_CONFIG.USER_ID,
created_at: new Date().toISOString(),
lat: this.missingBuildingData.lat,
lng: this.missingBuildingData.lng,
rimo_type: this.missingBuildingData.rimo_type
};
this.$set(this.faults, newFaultId, newFaultData);
this.addMissingBuildingMarker(newFaultId, newFaultData); // Add marker locally
this.showMissingBuildingModal = false;
this.missingBuildingData = null;
await this.saveFaults(newFaultId, false); // Save to backend
},
// New method to add marker for missing building
addMissingBuildingMarker(faultId, faultData) {
const rimoType = faultData.rimo_type || 'other';
const markerIconDef = this.getMarkerIcon(rimoType);
const hasFault = faultData && !faultData.done;
const newIcon = L.divIcon({
className: `custom-div-icon marker-missing-building marker-${rimoType} ${hasFault ? 'marker-has-fault' : ''}`,
html: `<div class="rimo-marker ${markerIconDef.class}"><i class="${markerIconDef.icon} rimo-icon"></i></div>`,
iconSize: [30, 30],
iconAnchor: [15, 30]
});
const newMarker = {
lat: faultData.lat,
lng: faultData.lng,
hausnummerId: faultId, // Root property for tt-map to find
options: {
icon: newIcon,
isMissingBuilding: true, // Custom flag
asyncPopupContent: () => this.generateMissingBuildingPopupHtml(faultId)
}
};
// Avoid duplicates - update existing if found
const existingIndex = this.missingBuildingMarkers.findIndex(m => m.hausnummerId === faultId);
if (existingIndex > -1) {
this.$set(this.missingBuildingMarkers, existingIndex, newMarker);
} else {
this.missingBuildingMarkers.push(newMarker);
}
},
getNormalizedRimoType(type) {
const lowerType = (type || '').toLowerCase();
if (lowerType.includes('greenfield')) return 'greenfield';
@@ -336,10 +573,20 @@ Vue.component('PreorderRimoTypeMap', {
const color = this.rimoTypeDefs[filterValue]?.color || '#6c757d';
return this.isFilterActive(filterValue) ? {backgroundColor: color, borderColor: color, color: 'white'} : {color: color, backgroundColor: 'white', borderColor: color};
},
saveEditingFault() {
this.saveFaults();
},
},
template: `
<div id="PreorderRimoTypeMap">
<tt-map ref="ttMap" :markers-data="filteredMapMarkers" :loading="isLoading" :config="mapConfig" :show-logo="true">
<tt-map ref="ttMap"
:markers-data="filteredMapMarkers"
:loading="isLoading"
:config="mapConfig"
:show-logo="true"
:contextmenu="true"
@contextmenu="handleMapContextMenu"
>
<template v-slot:tools>
<div class="main-filter-container">
<div class="map-filter-container">
@@ -387,6 +634,12 @@ Vue.component('PreorderRimoTypeMap', {
</div>
<span class="legend-text">Not2Connect</span>
</div>
<div class="legend-item">
<div class="legend-icon">
<div class="rimo-marker marker-residential marker-missing-building" style="width: 24px; height: 24px; border-width: 2px;"><i class="fas fa-home rimo-icon" style="font-size: 12px;"></i></div>
</div>
<span class="legend-text">Fehlendes Gebäude</span>
</div>
<div class="mobile-only-legend-buttons mt-3" v-if="selectedCampaign">
<tt-button :href="window.TT_CONFIG.BASE_PATH + '/Preorder/RimoTypeMapFaultsPDFAction?preordercampaign_id=' + selectedCampaign"
@@ -397,6 +650,7 @@ Vue.component('PreorderRimoTypeMap', {
</template>
</tt-map>
<!-- Faults List Modal -->
<div v-if="showFaultsModal" class="modal fade show" style="display: block; background-color: rgba(0,0,0,0.5);" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
@@ -411,7 +665,7 @@ Vue.component('PreorderRimoTypeMap', {
<div class="d-flex justify-content-between align-items-center">
<div class="pr-3">
<strong :class="{ 'text-muted': fault.done }">{{ fault.address }}</strong>
<div class="text-muted mb-1">Rimo ID: {{ fault.rimo_id }}</div>
<div class="text-muted mb-1" v-if="!fault.hausnummerId.startsWith('missing-')">Rimo ID: {{ fault.rimo_id }}</div>
<div v-if="!fault.done" class="mt-1">
<ul class="mb-0 small pl-3 text-danger font-weight-bold">
<li v-for="reason in fault.translated_reasons" :key="reason">{{ reason }}</li>
@@ -437,6 +691,34 @@ Vue.component('PreorderRimoTypeMap', {
</div>
</div>
</div>
<!-- New Missing Building Modal -->
<div v-if="showMissingBuildingModal" class="modal fade show" style="display: block; background-color: rgba(0,0,0,0.5);" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Fehlendes Gebäude markieren</h5>
<button type="button" class="close" @click="showMissingBuildingModal = false; missingBuildingData = null;" aria-label="Close"><span aria-hidden="true">×</span></button>
</div>
<div class="modal-body">
<p>Markieren Sie ein neues, fehlendes Gebäude an der Position:</p>
<p v-if="missingBuildingData"><strong>Lat:</strong> {{ missingBuildingData.lat.toFixed(6) }}, <strong>Lng:</strong> {{ missingBuildingData.lng.toFixed(6) }}</p>
<tt-select v-if="missingBuildingData"
v-model="missingBuildingData.rimo_type"
:options="rimoTypeOptionsForMissing"
label="RIMO-Typ des fehlenden Gebäudes"
:row="false"
/>
</div>
<div class="modal-footer">
<tt-button text="Abbrechen" @click="showMissingBuildingModal = false; missingBuildingData = null;" additional-class="btn-secondary"/>
<tt-button text="Speichern" @click="saveMissingBuilding" additional-class="btn-primary" icon="fas fa-save"/>
</div>
</div>
</div>
</div>
</div>
`
});
});

View File

@@ -3,7 +3,8 @@ Vue.component('tt-map', {
markersData: { type: Array, default: () => [] },
config: { type: Object, default: () => ({}) },
loading: { type: Boolean, default: false },
showLogo: { type: Boolean, default: false }
showLogo: { type: Boolean, default: false },
contextmenu: { type: Boolean, default: false }
},
data: () => ({
map: null,
@@ -61,6 +62,7 @@ Vue.component('tt-map', {
window.removeEventListener('resize', this.handleResize);
if (this.map) {
this.map.off('zoomend moveend', this.updateTooltipVisibility);
if (this.contextmenu) this.map.off('contextmenu', this.onMapContextMenu);
this.map.remove();
this.map = null;
}
@@ -115,6 +117,7 @@ Vue.component('tt-map', {
this.map.addLayer(this.markerLayer);
this.map.addLayer(this.nonClusteredLayer);
this.map.on('zoomend moveend', this.updateTooltipVisibility);
if (this.contextmenu) { this.map.on('contextmenu', this.onMapContextMenu); }
if (this.showLogo) {
const LogoControl = L.Control.extend({ onAdd: map => {
const container = L.DomUtil.create('div', 'leaflet-control-logo');
@@ -225,6 +228,7 @@ Vue.component('tt-map', {
},
handleSearchFocus() { this.showSearchResults = true; },
handleSearchBlur() { setTimeout(() => { this.showSearchResults = false; }, 200); },
onMapContextMenu(e) { e.originalEvent.preventDefault(); this.$emit('contextmenu', e); },
},
watch: {
markersData: { handler() { this.updateMarkers(); }, deep: true },