diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index b7fe107..bc54541 100755 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -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; diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index bd367c1..6bb4426 100755 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -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); diff --git a/backend/services/adminService.js b/backend/services/adminService.js index 2b29612..437356a 100755 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -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'); diff --git a/frontend/src/i18n/locales/de/admin.json b/frontend/src/i18n/locales/de/admin.json index d092456..fe0f325 100755 --- a/frontend/src/i18n/locales/de/admin.json +++ b/frontend/src/i18n/locales/de/admin.json @@ -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", diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index 492d848..b76b698 100755 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -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", diff --git a/frontend/src/views/admin/falukant/MapRegionsView.vue b/frontend/src/views/admin/falukant/MapRegionsView.vue index 7da45ba..4c34fc6 100755 --- a/frontend/src/views/admin/falukant/MapRegionsView.vue +++ b/frontend/src/views/admin/falukant/MapRegionsView.vue @@ -136,6 +136,28 @@ {{ $t('admin.falukant.map.unsaved') }} +
+ +
+ + +
+ +
+
+ {{ $t('admin.falukant.map.hierarchy.summary', { count: descendantRegions.length }) }} + {{ $t('admin.falukant.map.hierarchy.direct', { count: directChildRegions.length }) }} + {{ $t('admin.falukant.map.hierarchy.deeper', { count: deeperDescendantRegions.length }) }} +

{{ $t('admin.falukant.map.childRegions') }}

@@ -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;