Files
yourpart3/frontend/src/views/admin/falukant/MapRegionsView.vue
Torsten Schulz (local) 1ab9407e79
All checks were successful
Deploy to production / deploy (push) Successful in 2m56s
Refactor code structure for improved readability and maintainability; optimize performance across multiple modules.
2026-07-21 09:08:15 +02:00

815 lines
26 KiB
Vue
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="falukant-map-admin">
<div class="admin-header">
<h1>{{ $t('admin.falukant.map.title') }}</h1>
<p>{{ $t('admin.falukant.map.description') }}</p>
</div>
<div class="map-layout">
<div
class="map-container"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
>
<img
ref="mapImage"
src="/images/falukant/map.png"
class="map"
@load="onMapLoaded"
@dragstart.prevent
/>
<!-- vorhandene Regionen-Rechtecke -->
<template v-for="region in regions" :key="region.id">
<div
v-if="region.map"
class="region-rect"
:class="regionClasses(region)"
:style="rectStyle(region.map)"
@click.stop="selectRegion(region)"
:title="region.name"
></div>
</template>
<!-- aktuell gezeichneter Bereich -->
<div
v-if="drawingRect"
class="region-rect drawing"
:style="rectStyle(drawingRect)"
></div>
</div>
<div class="sidebar">
<h2>{{ $t('admin.falukant.map.regionList') }}</h2>
<ul class="region-list">
<li
v-for="region in regions"
:key="region.id"
:class="regionListClasses(region)"
@click="selectRegion(region)"
>
{{ region.name }}
<span v-if="region.map" class="coords">
({{ region.map.x }},{{ region.map.y }} {{ region.map.w }}×{{ region.map.h }})
</span>
<span v-else class="coords missing">
{{ $t('admin.falukant.map.noCoords') }}
</span>
</li>
</ul>
<SimpleTabs v-model="activeTab" :tabs="tabs" />
<div v-if="activeTab === 'regions'">
<div class="create-region">
<h3>{{ $t('admin.falukant.map.createRegion.title') }}</h3>
<table class="create-region-table">
<tbody>
<tr>
<td class="label-cell">{{ $t('admin.falukant.map.createRegion.type') }}</td>
<td class="field-cell">
<select v-model.number="newRegion.regionTypeId">
<option :value="null" disabled>{{ $t('admin.falukant.map.createRegion.selectType') }}</option>
<option v-for="t in regionTypes" :key="`rt-${t.id}`" :value="t.id">
{{ regionTypeLabel(t.labelTr) }}
</option>
</select>
</td>
</tr>
<tr>
<td class="label-cell">{{ $t('admin.falukant.map.createRegion.parent') }}</td>
<td class="field-cell">
<select v-model.number="newRegion.parentId" :disabled="!parentRegionEnabled">
<option :value="null" :disabled="parentRegionRequired">
{{ parentRegionEnabled ? $t('admin.falukant.map.createRegion.selectParent') : $t('admin.falukant.map.createRegion.noParent') }}
</option>
<option v-for="r in availableParentRegions" :key="`pr-${r.id}`" :value="r.id">
{{ r.name }}
</option>
</select>
</td>
</tr>
<tr>
<td class="label-cell">{{ $t('admin.falukant.map.createRegion.name') }}</td>
<td class="field-cell">
<input type="text" v-model.trim="newRegion.name" />
</td>
</tr>
<tr>
<td colspan="2" class="create-region-actions-cell">
<button
class="btn btn-primary"
:disabled="creatingRegion || !canCreateRegion"
@click="createRegion"
>
{{ creatingRegion ? $t('admin.falukant.map.createRegion.creating') : $t('admin.falukant.map.createRegion.create') }}
</button>
<span v-if="createRegionError" class="error-text">
{{ createRegionError }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="selectedRegion" class="details">
<h3>{{ selectedRegion.name }}</h3>
<p v-if="selectedRegion.map">
{{ $t('admin.falukant.map.currentRect') }}:
{{ selectedRegion.map.x }},{{ selectedRegion.map.y }}
{{ selectedRegion.map.w }}×{{ selectedRegion.map.h }}
</p>
<p v-else>
{{ $t('admin.falukant.map.noCoords') }}
</p>
<p class="hint">
{{ $t('admin.falukant.map.hintDraw') }}
</p>
<button
class="btn btn-primary"
:disabled="!selectedRegionDirty"
@click="saveSelectedRegion"
>
{{ $t('common.save') }}
</button>
<button
class="btn btn-secondary"
:disabled="!dirtyRegionIds.length"
@click="saveAllRegions"
>
{{ $t('admin.falukant.map.saveAll') }}
</button>
</div>
</div>
<div v-else-if="activeTab === 'distances'" class="connections">
<h3>{{ $t('admin.falukant.map.connectionsTitle') }}</h3>
<div class="connection-form">
<table class="connection-form-table">
<tbody>
<tr>
<td class="label-cell">
{{ $t('admin.falukant.map.source') }}
</td>
<td class="field-cell">
<div class="field-row">
<select v-model.number="newConnection.sourceRegionId">
<option :value="null" disabled>{{ $t('admin.falukant.map.selectSource') }}</option>
<option v-for="r in regions" :key="`src-${r.id}`" :value="r.id">
{{ r.name }}
</option>
</select>
<button
type="button"
class="btn mini icon"
@click="pickMode = 'source'"
:title="$t('admin.falukant.map.pickOnMap')"
>
</button>
</div>
</td>
</tr>
<tr>
<td class="label-cell">
{{ $t('admin.falukant.map.target') }}
</td>
<td class="field-cell">
<div class="field-row">
<select v-model.number="newConnection.targetRegionId">
<option :value="null" disabled>{{ $t('admin.falukant.map.selectTarget') }}</option>
<option v-for="r in regions" :key="`tgt-${r.id}`" :value="r.id">
{{ r.name }}
</option>
</select>
<button
type="button"
class="btn mini icon"
@click="pickMode = 'target'"
:title="$t('admin.falukant.map.pickOnMap')"
>
</button>
</div>
</td>
</tr>
<tr>
<td class="label-cell">
{{ $t('admin.falukant.map.mode') }}
</td>
<td class="field-cell">
<select v-model="newConnection.transportMode">
<option value="land">{{ $t('admin.falukant.map.modeLand') }}</option>
<option value="water">{{ $t('admin.falukant.map.modeWater') }}</option>
<option value="air">{{ $t('admin.falukant.map.modeAir') }}</option>
</select>
</td>
</tr>
<tr>
<td class="label-cell">
{{ $t('admin.falukant.map.distance') }}
</td>
<td class="field-cell">
<input
type="number"
min="0.1"
step="0.1"
v-model.number="newConnection.distance"
/>
</td>
</tr>
<tr>
<td colspan="2" class="connection-actions-cell">
<button
class="btn btn-primary"
:disabled="!newConnection.sourceRegionId || !newConnection.targetRegionId || !newConnection.distance"
@click="saveConnection"
>
{{ $t('admin.falukant.map.saveConnection') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<table v-if="connections && connections.length" class="connections-table">
<thead>
<tr>
<th>{{ $t('admin.falukant.map.source') }}</th>
<th>{{ $t('admin.falukant.map.target') }}</th>
<th>{{ $t('admin.falukant.map.mode') }}</th>
<th>{{ $t('admin.falukant.map.distance') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="c in connections" :key="c.id">
<td>{{ regionName(c.sourceRegionId) }}</td>
<td>{{ regionName(c.targetRegionId) }}</td>
<td>{{ modeLabel(c.transportMode) }}</td>
<td>{{ c.distance }}</td>
<td>
<button class="btn btn-secondary" @click="deleteConnection(c.id)">
{{ $t('common.delete') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import SimpleTabs from '@/components/SimpleTabs.vue';
import { confirmAction, showError } from '@/utils/feedback.js';
export default {
name: 'AdminFalukantMapRegionsView',
components: { SimpleTabs },
data() {
return {
regions: [],
regionTypes: [],
selectedRegion: null,
selectedRegionDirty: false,
dirtyRegionIds: [],
drawingRect: null,
startX: null,
startY: null,
currentX: 0,
currentY: 0,
connections: [],
newConnection: {
sourceRegionId: null,
targetRegionId: null,
transportMode: 'land',
distance: 1,
},
activeTab: 'regions',
tabs: [
{ value: 'regions', label: 'admin.falukant.map.tabs.regions' },
{ value: 'distances', label: 'admin.falukant.map.tabs.distances' },
],
pickMode: null,
newRegion: {
regionTypeId: null,
parentId: null,
name: '',
},
creatingRegion: false,
createRegionError: null,
};
},
computed: {
selectedRegionType() {
if (!this.newRegion.regionTypeId) return null;
return this.regionTypes.find((t) => t.id === this.newRegion.regionTypeId) || null;
},
parentRegionEnabled() {
return Boolean(this.selectedRegionType && this.selectedRegionType.parentId != null);
},
parentRegionRequired() {
return this.parentRegionEnabled;
},
availableParentRegions() {
if (!this.parentRegionEnabled) return [];
const parentTypeId = this.selectedRegionType.parentId;
return this.regions.filter((r) => r.regionTypeId === parentTypeId);
},
canCreateRegion() {
if (!this.newRegion.regionTypeId) return false;
if (!this.newRegion.name || !String(this.newRegion.name).trim()) return false;
if (this.parentRegionRequired && !this.newRegion.parentId) return false;
return true;
},
},
watch: {
'newRegion.regionTypeId': function () {
// Wenn der Typ keinen Parent erlaubt, Parent zurücksetzen
if (!this.parentRegionEnabled) {
this.newRegion.parentId = null;
} else if (this.newRegion.parentId) {
// Falls der gewählte Parent nicht mehr passt (z.B. Typ gewechselt), resetten
const ok = this.availableParentRegions.some((r) => r.id === this.newRegion.parentId);
if (!ok) this.newRegion.parentId = null;
}
},
},
async mounted() {
await this.loadRegionTypes();
await this.loadRegions();
await this.loadConnections();
},
methods: {
onMapLoaded() {
const bounds = this.$refs.mapImage.getBoundingClientRect();
this.mapWidth = bounds.width;
this.mapHeight = bounds.height;
},
modeLabel(mode) {
if (!mode) return '';
const key = String(mode);
const tKey = `falukant.branch.transport.modes.${key}`;
const translated = this.$t(tKey);
if (!translated || translated === tKey) return mode;
return translated;
},
regionTypeLabel(labelTr) {
if (!labelTr) return '';
const raw = String(labelTr);
const keys = [
`falukant.politics.regionLevels.${raw}`,
`falukant.regionType.${raw}`,
];
for (const k of keys) {
const tr = this.$t(k);
if (tr && tr !== k) return tr;
}
return raw;
},
async loadRegions() {
try {
const { data } = await apiClient.get('/api/admin/falukant/regions/all');
// Sicherstellen, dass map-Objekte existieren oder null sind
this.regions = (data || []).map(r => ({
...r,
map: r.map || null,
}));
} catch (error) {
console.error('Error loading Falukant regions:', error);
}
},
async loadRegionTypes() {
try {
const { data } = await apiClient.get('/api/admin/falukant/region-types');
this.regionTypes = data || [];
} catch (error) {
console.error('Error loading Falukant region types:', error);
this.regionTypes = [];
}
},
async createRegion() {
if (this.creatingRegion || !this.canCreateRegion) return;
this.creatingRegion = true;
this.createRegionError = null;
try {
const payload = {
name: this.newRegion.name,
regionTypeId: this.newRegion.regionTypeId,
parentId: this.parentRegionEnabled ? this.newRegion.parentId : null,
};
const { data } = await apiClient.post('/api/admin/falukant/regions', payload);
await this.loadRegions();
if (data && data.id) {
const created = this.regions.find((r) => r.id === data.id) || null;
if (created) {
this.activeTab = 'regions';
this.selectRegion(created);
}
}
// Form zurücksetzen (Typ beibehalten, Name leeren)
this.newRegion.name = '';
if (this.parentRegionEnabled) {
this.newRegion.parentId = null;
}
} catch (error) {
console.error('Error creating region:', error);
const errKey = error?.response?.data?.error;
this.createRegionError = errKey ? String(errKey) : this.$t('admin.falukant.map.createRegion.error');
} finally {
this.creatingRegion = false;
}
},
async loadConnections() {
try {
const { data } = await apiClient.get('/api/admin/falukant/region-distances');
this.connections = data || [];
} catch (error) {
console.error('Error loading region distances:', error);
this.connections = [];
}
},
rectStyle(rect) {
if (!rect || !this.mapWidth || !this.mapHeight) return {};
// Unterstützt alte absolute Koordinaten (Pixel) und neue relative (01)
const toPxX = (v) => (v >= 0 && v <= 1 ? v * this.mapWidth : v);
const toPxY = (v) => (v >= 0 && v <= 1 ? v * this.mapHeight : v);
const x = toPxX(rect.x);
const y = toPxY(rect.y);
const w = toPxX(rect.w);
const h = toPxY(rect.h);
return {
top: `${y}px`,
left: `${x}px`,
width: `${w}px`,
height: `${h}px`,
};
},
regionClasses(region) {
return {
selected: this.selectedRegion && this.selectedRegion.id === region.id,
dirty: this.dirtyRegionIds.includes(region.id),
source: this.newConnection.sourceRegionId === region.id,
target: this.newConnection.targetRegionId === region.id,
};
},
regionListClasses(region) {
return {
selected: this.selectedRegion && this.selectedRegion.id === region.id,
dirty: this.dirtyRegionIds.includes(region.id),
source: this.newConnection.sourceRegionId === region.id,
target: this.newConnection.targetRegionId === region.id,
};
},
selectRegion(region) {
// Wenn wir im "auf Karte wählen"-Modus sind, setze Quelle/Ziel für die Verbindung
if (this.pickMode === 'source') {
this.newConnection.sourceRegionId = region.id;
this.pickMode = null;
} else if (this.pickMode === 'target') {
this.newConnection.targetRegionId = region.id;
this.pickMode = null;
}
this.selectedRegion = region;
this.selectedRegionDirty = this.dirtyRegionIds.includes(region.id);
this.drawingRect = null;
},
onMouseDown(event) {
// Zeichnen von Rechtecken nur im "Positionen"-Tab
if (this.activeTab !== 'regions') return;
if (!this.selectedRegion) return;
const bounds = this.$refs.mapImage.getBoundingClientRect();
this.startX = event.clientX - bounds.left;
this.startY = event.clientY - bounds.top;
this.currentX = this.startX;
this.currentY = this.startY;
this.updateDrawingRect();
event.preventDefault();
},
onMouseMove(event) {
// Zeichnen nur im "Positionen"-Tab
if (this.activeTab !== 'regions') return;
if (this.startX === null || this.startY === null) return;
const bounds = this.$refs.mapImage.getBoundingClientRect();
this.currentX = event.clientX - bounds.left;
this.currentY = event.clientY - bounds.top;
this.updateDrawingRect();
},
onMouseUp() {
// Im Entfernungen-Tab niemals Koordinaten verändern
if (this.activeTab !== 'regions') {
this.startX = null;
this.startY = null;
this.drawingRect = null;
return;
}
if (!this.selectedRegion || !this.drawingRect) {
this.startX = null;
this.startY = null;
return;
}
// Nur übernehmen, wenn tatsächlich ein Rechteck "aufgezogen" wurde
if (this.drawingRect.w < 3 || this.drawingRect.h < 3) {
this.drawingRect = null;
this.startX = null;
this.startY = null;
return;
}
// Übernehme gezeichnetes Rechteck in ausgewählte Region
const bounds = this.$refs.mapImage.getBoundingClientRect();
const updatedMap = {
x: this.drawingRect.x / bounds.width,
y: this.drawingRect.y / bounds.height,
w: this.drawingRect.w / bounds.width,
h: this.drawingRect.h / bounds.height,
};
this.selectedRegion.map = updatedMap;
this.markRegionDirty(this.selectedRegion.id);
this.startX = null;
this.startY = null;
this.drawingRect = null;
},
updateDrawingRect() {
if (this.startX === null || this.startY === null) return;
const x = Math.min(this.startX, this.currentX);
const y = Math.min(this.startY, this.currentY);
const w = Math.abs(this.currentX - this.startX);
const h = Math.abs(this.currentY - this.startY);
this.drawingRect = {
x: Math.round(x),
y: Math.round(y),
w: Math.round(w),
h: Math.round(h),
};
},
markRegionDirty(id) {
if (!this.dirtyRegionIds.includes(id)) {
this.dirtyRegionIds.push(id);
}
if (this.selectedRegion && this.selectedRegion.id === id) {
this.selectedRegionDirty = true;
}
},
async saveRegion(region) {
if (!region || !region.map) return;
await apiClient.put(`/api/admin/falukant/regions/${region.id}/map`, {
map: region.map,
});
// lokal als nicht mehr dirty markieren
this.dirtyRegionIds = this.dirtyRegionIds.filter(id => id !== region.id);
if (this.selectedRegion && this.selectedRegion.id === region.id) {
this.selectedRegionDirty = false;
}
},
async saveSelectedRegion() {
if (!this.selectedRegion || !this.selectedRegion.map) return;
try {
await this.saveRegion(this.selectedRegion);
// Liste aktualisieren, damit andere Einträge auch aktualisierte Daten sehen
await this.loadRegions();
const refreshed = this.regions.find(r => r.id === this.selectedRegion.id);
if (refreshed) {
this.selectedRegion = refreshed;
}
} catch (error) {
console.error('Error saving region map:', error);
}
},
async saveAllRegions() {
if (!this.dirtyRegionIds.length) return;
try {
const dirtyIds = [...this.dirtyRegionIds];
for (const id of dirtyIds) {
const region = this.regions.find(r => r.id === id && r.map);
if (!region) continue;
await this.saveRegion(region);
}
await this.loadRegions();
if (this.selectedRegion) {
const refreshed = this.regions.find(r => r.id === this.selectedRegion.id);
if (refreshed) {
this.selectedRegion = refreshed;
}
}
} catch (error) {
console.error('Error saving all region maps:', error);
}
},
async saveConnection() {
try {
await apiClient.post('/api/admin/falukant/region-distances', {
sourceRegionId: this.newConnection.sourceRegionId,
targetRegionId: this.newConnection.targetRegionId,
transportMode: this.newConnection.transportMode,
distance: this.newConnection.distance,
});
await this.loadConnections();
} catch (error) {
console.error('Error saving region distance:', error);
showError(this, this.$t('admin.falukant.map.errorSaveConnection'));
}
},
async deleteConnection(id) {
const confirmed = await confirmAction(this, {
title: this.$t('admin.falukant.map.connectionsTitle'),
message: this.$t('admin.falukant.map.confirmDeleteConnection')
});
if (!confirmed) return;
try {
await apiClient.delete(`/api/admin/falukant/region-distances/${id}`);
await this.loadConnections();
} catch (error) {
console.error('Error deleting region distance:', error);
showError(this, this.$t('admin.falukant.map.errorDeleteConnection'));
}
},
regionName(id) {
const r = this.regions.find(x => x.id === id);
return r ? r.name : `#${id}`;
},
},
};
</script>
<style scoped>
.falukant-map-admin {
padding: 10px;
}
.map-layout {
display: flex;
gap: 20px;
}
.map-container {
position: relative;
display: inline-block;
}
.map {
max-width: 800px;
border: 1px solid #ccc;
}
.region-rect {
position: absolute;
border: 2px solid rgba(0, 128, 255, 0.7);
background-color: rgba(0, 128, 255, 0.2);
cursor: pointer;
}
.region-rect.selected {
border-color: rgba(255, 128, 0, 0.9);
background-color: rgba(255, 128, 0, 0.25);
}
.region-rect.drawing {
border-style: dashed;
}
.sidebar {
min-width: 260px;
max-width: 320px;
}
.region-list {
list-style: none;
padding: 0;
margin: 0 0 1rem 0;
max-height: 400px;
overflow: auto;
border: 1px solid #ccc;
}
.region-list li {
padding: 4px 8px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.region-list li:nth-child(odd) {
background-color: #f8f8f8;
}
.region-list li.selected {
background-color: #e0f0ff;
font-weight: bold;
}
.region-rect.source,
.region-rect.target {
border-color: rgba(0, 160, 0, 0.9);
background-color: rgba(0, 200, 0, 0.35);
}
.region-list li.source,
.region-list li.target {
background-color: #d2f5d2;
}
.region-list li.dirty {
font-style: italic;
}
.coords {
font-size: 0.8rem;
color: #555;
}
.coords.missing {
color: #b00;
}
.details {
margin-top: 1rem;
}
.create-region {
margin-top: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.create-region-table {
width: 100%;
border-collapse: collapse;
}
.create-region-actions-cell {
text-align: right;
}
.error-text {
margin-left: 8px;
color: #b00;
font-size: 0.85rem;
}
.hint {
font-size: 0.85rem;
color: #555;
margin-bottom: 0.5rem;
}
.connection-form-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 0.5rem;
}
.connection-form-table th,
.connection-form-table td {
padding: 2px 4px;
vertical-align: middle;
}
.label-cell {
width: 1%;
white-space: nowrap;
font-weight: 600;
}
.field-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.field-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
}
.connection-actions-cell {
text-align: right;
}
.btn.mini {
padding: 2px 6px;
font-size: 0.8rem;
}
.btn.mini.icon {
}
</style>