feat(admin): implement region renaming functionality and update UI components

This commit is contained in:
Torsten Schulz (local)
2026-08-14 13:50:49 +02:00
parent a6a1ce7726
commit edb40c9785
6 changed files with 244 additions and 0 deletions

View File

@@ -57,6 +57,7 @@ class AdminController {
this.getFalukantAllRegions = this.getFalukantAllRegions.bind(this);
this.getFalukantRegionTypes = this.getFalukantRegionTypes.bind(this);
this.createFalukantRegion = this.createFalukantRegion.bind(this);
this.updateFalukantRegion = this.updateFalukantRegion.bind(this);
this.deleteFalukantRegion = this.deleteFalukantRegion.bind(this);
this.updateFalukantRegionMap = this.updateFalukantRegionMap.bind(this);
this.getRegionDistances = this.getRegionDistances.bind(this);
@@ -623,6 +624,19 @@ class AdminController {
}
}
async updateFalukantRegion(req, res) {
try {
const { userid: userId } = req.headers;
const region = await AdminService.updateFalukantRegion(userId, req.params.id, req.body || {});
res.status(200).json(region);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' ? 403
: (error.message === 'regionNotFound' ? 404 : 400);
res.status(status).json({ error: error.message });
}
}
async deleteFalukantRegion(req, res) {
try {
const { userid: userId } = req.headers;

View File

@@ -65,6 +65,7 @@ router.get('/falukant/region-types', authenticate, adminController.getFalukantRe
router.get('/falukant/regions', authenticate, adminController.getFalukantRegions);
router.get('/falukant/regions/all', authenticate, adminController.getFalukantAllRegions);
router.post('/falukant/regions', authenticate, adminController.createFalukantRegion);
router.put('/falukant/regions/:id', authenticate, adminController.updateFalukantRegion);
router.delete('/falukant/regions/:id', authenticate, adminController.deleteFalukantRegion);
router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalukantRegionMap);
router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances);

View File

@@ -934,6 +934,30 @@ class AdminService {
return region;
}
async updateFalukantRegion(userId, regionId, { name } = {}) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const regionName = String(name || '').trim();
if (!regionName) {
throw new Error('missingName');
}
const region = await RegionData.findByPk(regionId);
if (!region) {
throw new Error('regionNotFound');
}
region.name = regionName;
await region.save();
return RegionData.findByPk(region.id, {
attributes: ['id', 'name', 'map', 'regionTypeId', 'parentId'],
include: [{ model: RegionType, as: 'regionType', attributes: ['id', 'labelTr', 'parentId'] }],
});
}
async deleteFalukantRegion(userId, regionId) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');

View File

@@ -267,6 +267,20 @@
"selectRegionHint": "Wähle eine Region aus der Liste oder direkt auf der Karte aus.",
"childRegions": "Direkte Unterregionen",
"noChildRegions": "Keine direkten Unterregionen zugeordnet.",
"rename": {
"label": "Regionsname",
"save": "Umbenennen",
"saving": "Benenne um…",
"success": "Region wurde umbenannt.",
"missingName": "Bitte einen Regionsnamen eingeben.",
"error": "Die Region konnte nicht umbenannt werden."
},
"hierarchy": {
"title": "Unterregionen-Hierarchie",
"summary": "{count} Unterregionen werden hervorgehoben.",
"direct": "Direkt: {count}",
"deeper": "Tiefer: {count}"
},
"unknownType": "Unbekannter Typ",
"coordsCompact": "X {x}, Y {y} · B {w} × H {h}",
"noCoords": "Keine Koordinaten gesetzt",

View File

@@ -322,6 +322,20 @@
"selectRegionHint": "Select a region from the list or directly on the map.",
"childRegions": "Direct child regions",
"noChildRegions": "No direct child regions assigned.",
"rename": {
"label": "Region name",
"save": "Rename",
"saving": "Renaming…",
"success": "Region renamed.",
"missingName": "Enter a region name.",
"error": "Could not rename the region."
},
"hierarchy": {
"title": "Child-region hierarchy",
"summary": "{count} child regions highlighted.",
"direct": "Direct: {count}",
"deeper": "Deeper: {count}"
},
"unknownType": "Unknown type",
"coordsCompact": "X {x}, Y {y} · W {w} × H {h}",
"noCoords": "No coordinates set",

View File

@@ -136,6 +136,28 @@
</span>
<span v-if="selectedRegionDirty" class="unsaved-badge">{{ $t('admin.falukant.map.unsaved') }}</span>
</div>
<form class="rename-region" @submit.prevent="renameSelectedRegion">
<label for="selected-region-name">{{ $t('admin.falukant.map.rename.label') }}</label>
<div class="rename-region-controls">
<input
id="selected-region-name"
v-model="renameName"
type="text"
required
:disabled="renamingRegion"
:aria-describedby="renameError ? 'selected-region-name-error' : null"
/>
<button class="btn btn-secondary" type="submit" :disabled="!canRenameSelectedRegion">
{{ renamingRegion ? $t('admin.falukant.map.rename.saving') : $t('admin.falukant.map.rename.save') }}
</button>
</div>
<p v-if="renameError" id="selected-region-name-error" class="error-text rename-error" role="alert">{{ renameError }}</p>
</form>
<section class="hierarchy-summary" :aria-label="$t('admin.falukant.map.hierarchy.title')">
<span>{{ $t('admin.falukant.map.hierarchy.summary', { count: descendantRegions.length }) }}</span>
<span class="hierarchy-legend"><i class="legend-direct"></i>{{ $t('admin.falukant.map.hierarchy.direct', { count: directChildRegions.length }) }}</span>
<span class="hierarchy-legend"><i class="legend-deep"></i>{{ $t('admin.falukant.map.hierarchy.deeper', { count: deeperDescendantRegions.length }) }}</span>
</section>
<section class="child-regions" :aria-label="$t('admin.falukant.map.childRegions')">
<div class="child-regions-heading">
<h4>{{ $t('admin.falukant.map.childRegions') }}</h4>
@@ -339,6 +361,9 @@ export default {
savingRegion: false,
savingAllRegions: false,
deletingRegion: false,
renameName: '',
renameError: null,
renamingRegion: false,
};
},
computed: {
@@ -369,6 +394,40 @@ export default {
.filter((region) => String(region.parentId) === String(this.selectedRegion.id))
.sort((a, b) => a.name.localeCompare(b.name));
},
descendantDepthById() {
if (!this.selectedRegion) return new Map();
const childrenByParentId = new Map();
this.regions.forEach((region) => {
const parentId = String(region.parentId);
const children = childrenByParentId.get(parentId) || [];
children.push(region);
childrenByParentId.set(parentId, children);
});
const depths = new Map();
const visit = (parentId, depth, visited) => {
(childrenByParentId.get(String(parentId)) || []).forEach((child) => {
if (visited.has(String(child.id))) return;
depths.set(String(child.id), depth);
const nextVisited = new Set(visited);
nextVisited.add(String(child.id));
visit(child.id, depth + 1, nextVisited);
});
};
visit(this.selectedRegion.id, 1, new Set([String(this.selectedRegion.id)]));
return depths;
},
descendantRegions() {
return this.regions
.filter((region) => this.descendantDepthById.has(String(region.id)))
.sort((a, b) => a.name.localeCompare(b.name));
},
deeperDescendantRegions() {
return this.descendantRegions.filter((region) => this.descendantDepthById.get(String(region.id)) > 1);
},
canRenameSelectedRegion() {
return Boolean(this.selectedRegion && String(this.renameName || '').trim()
&& String(this.renameName).trim() !== this.selectedRegion.name && !this.renamingRegion);
},
},
watch: {
'newRegion.regionTypeId': function () {
@@ -505,6 +564,8 @@ export default {
regionClasses(region) {
return {
selected: this.selectedRegion && this.selectedRegion.id === region.id,
'direct-descendant': this.descendantDepthById.get(String(region.id)) === 1,
'deep-descendant': this.descendantDepthById.get(String(region.id)) > 1,
dirty: this.dirtyRegionIds.includes(region.id),
source: this.newConnection.sourceRegionId === region.id,
target: this.newConnection.targetRegionId === region.id,
@@ -513,6 +574,8 @@ export default {
regionListClasses(region) {
return {
selected: this.selectedRegion && this.selectedRegion.id === region.id,
'direct-descendant': this.descendantDepthById.get(String(region.id)) === 1,
'deep-descendant': this.descendantDepthById.get(String(region.id)) > 1,
dirty: this.dirtyRegionIds.includes(region.id),
source: this.newConnection.sourceRegionId === region.id,
target: this.newConnection.targetRegionId === region.id,
@@ -529,9 +592,32 @@ export default {
}
this.selectedRegion = region;
this.renameName = region.name;
this.renameError = null;
this.selectedRegionDirty = this.dirtyRegionIds.includes(region.id);
this.drawingRect = null;
},
async renameSelectedRegion() {
if (!this.canRenameSelectedRegion) return;
const region = this.selectedRegion;
const name = String(this.renameName).trim();
this.renamingRegion = true;
this.renameError = null;
try {
const { data } = await apiClient.put(`/api/admin/falukant/regions/${region.id}`, { name });
const index = this.regions.findIndex((item) => item.id === region.id);
if (index !== -1) this.regions.splice(index, 1, { ...this.regions[index], ...data });
this.selectedRegion = this.regions.find((item) => item.id === region.id) || data;
this.renameName = this.selectedRegion.name;
showSuccess(this, this.$t('admin.falukant.map.rename.success'));
} catch (error) {
console.error('Error renaming Falukant region:', error);
const errorKey = error?.response?.data?.error;
this.renameError = this.$t(`admin.falukant.map.rename.${errorKey === 'missingName' ? 'missingName' : 'error'}`);
} finally {
this.renamingRegion = false;
}
},
onMouseDown(event) {
// Zeichnen von Rechtecken nur im "Positionen"-Tab
if (this.activeTab !== 'regions') return;
@@ -801,6 +887,16 @@ export default {
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.75);
}
.region-rect.direct-descendant {
border-color: rgba(0, 128, 128, 0.95);
background-color: rgba(0, 148, 136, 0.28);
}
.region-rect.deep-descendant {
border-color: rgba(60, 166, 158, 0.9);
background-color: rgba(102, 196, 187, 0.2);
}
.region-rect.drawing {
border-style: dashed;
}
@@ -854,6 +950,21 @@ export default {
box-shadow: inset 3px 0 0 #1976d2;
}
.region-list li.direct-descendant .region-list-entry {
background: #d8f0ed;
box-shadow: inset 3px 0 0 #008c84;
}
.region-list li.deep-descendant .region-list-entry {
background: #ecf8f6;
box-shadow: inset 3px 0 0 #76bdb6;
}
.region-list li.selected .region-list-entry {
background-color: #dcecff;
box-shadow: inset 3px 0 0 #1976d2;
}
.region-list-name {
min-width: 0;
overflow: hidden;
@@ -952,6 +1063,72 @@ export default {
color: #6b5a39;
}
.rename-region {
margin-top: 12px;
}
.rename-region label {
display: block;
margin-bottom: 4px;
color: #41556a;
font-size: 0.78rem;
font-weight: 700;
}
.rename-region-controls {
display: flex;
gap: 6px;
}
.rename-region-controls input {
min-width: 0;
flex: 1;
}
.rename-error {
margin: 5px 0 0;
}
.hierarchy-summary {
display: flex;
flex-wrap: wrap;
gap: 5px 10px;
margin-top: 12px;
padding: 7px 8px;
border: 1px solid #cfe5e2;
background: #f2faf9;
color: #315c59;
font-size: 0.78rem;
}
.hierarchy-summary > :first-child {
flex-basis: 100%;
font-weight: 700;
}
.hierarchy-legend {
display: inline-flex;
align-items: center;
gap: 4px;
}
.hierarchy-legend i {
display: inline-block;
width: 10px;
height: 10px;
border: 1px solid;
}
.legend-direct {
border-color: #008c84 !important;
background: #4eb5ac;
}
.legend-deep {
border-color: #76bdb6 !important;
background: #b9e1dc;
}
.child-regions {
margin-top: 12px;
border: 1px solid #dfe7ee;