Added mainMap to visualize all configured areas. Included reference layers for existing areas when editing or creating a new polygon to prevent overlap.
526 lines
15 KiB
Vue
526 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { ref, reactive, nextTick, onBeforeUnmount, computed, watch } from 'vue'
|
|
import { BARI_BOUNDS, BARI_CENTER, isWithinBariBounds } from '~/utils/bari'
|
|
|
|
definePageMeta({
|
|
middleware: 'auth',
|
|
auth: { role: 'ADMIN' }
|
|
})
|
|
|
|
useHead({ title: 'Aree - Admin' })
|
|
|
|
const { getAreas, createArea, updateArea, deleteArea } = useAdmin()
|
|
const { data: areasRes, pending, refresh } = useAsyncData('admin-areas', () => getAreas())
|
|
|
|
const isCreating = ref(false)
|
|
const editingAreaId = ref<number | null>(null)
|
|
const formData = reactive({
|
|
name: '',
|
|
description: '',
|
|
geojson: ''
|
|
})
|
|
const jsonError = ref('')
|
|
const actionError = ref('')
|
|
const deletingAreaId = ref<number | null>(null)
|
|
const areaPendingDelete = ref<any | null>(null)
|
|
|
|
let map: any = null
|
|
let mainMap: any = null
|
|
let polygon: any = null
|
|
const mapPoints = ref<[number, number][]>([])
|
|
|
|
const modalTitle = computed(() => editingAreaId.value ? 'Modifica Area' : 'Nuova Area')
|
|
const submitLabel = computed(() => editingAreaId.value ? 'Salva Modifiche' : 'Crea Area')
|
|
const isEditing = computed(() => editingAreaId.value !== null)
|
|
const areas = computed(() => areasRes.value?.areas || [])
|
|
const areaDeleteMessage = computed(() =>
|
|
areaPendingDelete.value ? `Vuoi eliminare area "${areaPendingDelete.value.name}"? Operazione irreversibile.` : ''
|
|
)
|
|
|
|
const initMainMap = async () => {
|
|
const mapElement = document.getElementById('mainMap')
|
|
if (!mapElement) return
|
|
|
|
const L = (await import('leaflet')).default
|
|
|
|
if (mainMap) {
|
|
mainMap.remove()
|
|
mainMap = null
|
|
}
|
|
|
|
const bariBounds = L.latLngBounds(
|
|
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
|
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
|
)
|
|
|
|
mainMap = L.map('mainMap', {
|
|
maxBounds: bariBounds,
|
|
maxBoundsViscosity: 1,
|
|
minZoom: 11
|
|
}).setView(BARI_CENTER, 12)
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap'
|
|
}).addTo(mainMap)
|
|
|
|
const featureGroup = L.featureGroup().addTo(mainMap)
|
|
let hasLayers = false
|
|
|
|
areas.value.forEach((area: any) => {
|
|
if (area.boundary_geojson) {
|
|
try {
|
|
const layer = L.geoJSON(JSON.parse(area.boundary_geojson), {
|
|
style: {
|
|
color: '#0d6efd',
|
|
weight: 2,
|
|
fillOpacity: 0.2
|
|
}
|
|
}).bindPopup(`<strong>${area.name}</strong><br/>${area.description || ''}`)
|
|
|
|
layer.addTo(featureGroup)
|
|
hasLayers = true
|
|
} catch (e) {
|
|
console.error('Invalid geojson for area', area.id, e)
|
|
}
|
|
}
|
|
})
|
|
|
|
if (hasLayers) {
|
|
mainMap.fitBounds(featureGroup.getBounds())
|
|
} else {
|
|
mainMap.fitBounds(bariBounds)
|
|
}
|
|
}
|
|
|
|
watch([areas, pending], () => {
|
|
if (!pending.value) {
|
|
if (areas.value.length > 0) {
|
|
nextTick(() => {
|
|
initMainMap()
|
|
})
|
|
} else if (mainMap) {
|
|
mainMap.remove()
|
|
mainMap = null
|
|
}
|
|
}
|
|
}, { immediate: true })
|
|
|
|
const renderExistingAreasInModal = (L: any, currentMap: any) => {
|
|
areas.value.forEach((area: any) => {
|
|
if (area.id === editingAreaId.value) return
|
|
|
|
if (area.boundary_geojson) {
|
|
try {
|
|
L.geoJSON(JSON.parse(area.boundary_geojson), {
|
|
style: {
|
|
color: '#6c757d',
|
|
weight: 2,
|
|
dashArray: '5, 5',
|
|
fillOpacity: 0.1
|
|
},
|
|
interactive: false
|
|
}).addTo(currentMap)
|
|
} catch(e) {}
|
|
}
|
|
})
|
|
}
|
|
|
|
const openCreateModal = () => {
|
|
actionError.value = ''
|
|
editingAreaId.value = null
|
|
formData.name = ''
|
|
formData.description = ''
|
|
formData.geojson = ''
|
|
jsonError.value = ''
|
|
mapPoints.value = []
|
|
isCreating.value = true
|
|
|
|
nextTick(async () => {
|
|
const L = (await import('leaflet')).default
|
|
|
|
if (map) {
|
|
map.remove()
|
|
map = null
|
|
}
|
|
|
|
const bariBounds = L.latLngBounds(
|
|
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
|
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
|
)
|
|
|
|
map = L.map('areaMap', {
|
|
maxBounds: bariBounds,
|
|
maxBoundsViscosity: 1,
|
|
minZoom: 12
|
|
}).setView(BARI_CENTER, 12)
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap'
|
|
}).addTo(map)
|
|
|
|
polygon = L.polygon([], { color: 'red' }).addTo(map)
|
|
map.fitBounds(bariBounds)
|
|
|
|
renderExistingAreasInModal(L, map)
|
|
|
|
map.on('click', (e: any) => {
|
|
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
|
|
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
|
|
return
|
|
}
|
|
|
|
jsonError.value = ''
|
|
mapPoints.value.push([e.latlng.lat, e.latlng.lng])
|
|
polygon.setLatLngs(mapPoints.value)
|
|
updateGeojson()
|
|
})
|
|
})
|
|
}
|
|
|
|
const loadPolygonFromGeoJson = (boundaryGeojson: any) => {
|
|
const coordinates = boundaryGeojson?.coordinates?.[0]
|
|
if (!Array.isArray(coordinates)) {
|
|
mapPoints.value = []
|
|
return
|
|
}
|
|
|
|
const normalized = coordinates
|
|
.slice(0, -1)
|
|
.map(([longitude, latitude]: [number, number]) => [latitude, longitude] as [number, number])
|
|
|
|
mapPoints.value = normalized
|
|
if (polygon) {
|
|
polygon.setLatLngs(mapPoints.value)
|
|
}
|
|
}
|
|
|
|
const openEditModal = (area: any) => {
|
|
actionError.value = ''
|
|
editingAreaId.value = area.id
|
|
formData.name = area.name || ''
|
|
formData.description = area.description || ''
|
|
formData.geojson = area.boundary_geojson || ''
|
|
jsonError.value = ''
|
|
mapPoints.value = []
|
|
isCreating.value = true
|
|
|
|
nextTick(async () => {
|
|
const L = (await import('leaflet')).default
|
|
|
|
if (map) {
|
|
map.remove()
|
|
map = null
|
|
}
|
|
|
|
const bariBounds = L.latLngBounds(
|
|
[BARI_BOUNDS.south, BARI_BOUNDS.west],
|
|
[BARI_BOUNDS.north, BARI_BOUNDS.east]
|
|
)
|
|
|
|
map = L.map('areaMap', {
|
|
maxBounds: bariBounds,
|
|
maxBoundsViscosity: 1,
|
|
minZoom: 12
|
|
}).setView(BARI_CENTER, 12)
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap'
|
|
}).addTo(map)
|
|
console.log(JSON.parse(area.boundary_geojson))
|
|
L.geoJSON(JSON.parse(area.boundary_geojson), {
|
|
style: {
|
|
color: '#0d6efd',
|
|
weight: 2,
|
|
fillOpacity: 0.2
|
|
}
|
|
}).addTo(map)
|
|
|
|
polygon = L.polygon([], { color: 'red' }).addTo(map)
|
|
map.fitBounds(bariBounds)
|
|
|
|
renderExistingAreasInModal(L, map)
|
|
|
|
loadPolygonFromGeoJson(area.boundary_geojson)
|
|
|
|
map.on('click', (e: any) => {
|
|
if (!isWithinBariBounds(e.latlng.lat, e.latlng.lng)) {
|
|
jsonError.value = 'Puoi disegnare aree solo entro confini del Comune di Bari.'
|
|
return
|
|
}
|
|
|
|
jsonError.value = ''
|
|
mapPoints.value.push([e.latlng.lat, e.latlng.lng])
|
|
polygon.setLatLngs(mapPoints.value)
|
|
updateGeojson()
|
|
})
|
|
})
|
|
}
|
|
|
|
const clearMap = () => {
|
|
mapPoints.value = []
|
|
if (polygon) {
|
|
polygon.setLatLngs([])
|
|
}
|
|
formData.geojson = ''
|
|
}
|
|
|
|
const updateGeojson = () => {
|
|
if (mapPoints.value.length < 3) {
|
|
formData.geojson = ''
|
|
return
|
|
}
|
|
const coords = mapPoints.value.map(p => [p[1], p[0]] as [number, number]) // Lng, Lat
|
|
if (coords[0]) {
|
|
coords.push([...coords[0]])
|
|
}
|
|
|
|
formData.geojson = JSON.stringify({
|
|
type: "Polygon",
|
|
coordinates: [coords]
|
|
}, null, 2)
|
|
}
|
|
|
|
const closeCreateModal = () => {
|
|
isCreating.value = false
|
|
editingAreaId.value = null
|
|
if (map) {
|
|
map.remove()
|
|
map = null
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
if (map) map.remove()
|
|
if (mainMap) mainMap.remove()
|
|
})
|
|
|
|
const handleCreate = async () => {
|
|
jsonError.value = ''
|
|
let parsedGeojson = null
|
|
|
|
try {
|
|
parsedGeojson = JSON.parse(formData.geojson)
|
|
if (!parsedGeojson.type || !parsedGeojson.coordinates) {
|
|
throw new Error('GeoJSON non valido. Richiesto "type" e "coordinates".')
|
|
}
|
|
|
|
const coordinates = parsedGeojson.coordinates?.[0] || []
|
|
const outsideBari = coordinates.some(([longitude, latitude]: [number, number]) => !isWithinBariBounds(latitude, longitude))
|
|
if (outsideBari) {
|
|
throw new Error('Area non valida. Tutti i punti devono essere nel Comune di Bari.')
|
|
}
|
|
} catch (e: any) {
|
|
jsonError.value = e.message || 'Formato JSON non valido.'
|
|
return
|
|
}
|
|
|
|
try {
|
|
const payload = {
|
|
name: formData.name,
|
|
description: formData.description || undefined,
|
|
boundary_geojson: parsedGeojson
|
|
}
|
|
|
|
const res = editingAreaId.value
|
|
? await updateArea(editingAreaId.value, payload)
|
|
: await createArea(payload)
|
|
|
|
if (res.ok) {
|
|
await refresh()
|
|
closeCreateModal()
|
|
actionError.value = ''
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to save area', err)
|
|
actionError.value = 'Errore durante il salvataggio dell\'area.'
|
|
}
|
|
}
|
|
|
|
const askDeleteArea = (area: any) => {
|
|
areaPendingDelete.value = area
|
|
}
|
|
|
|
const closeDeleteAreaModal = () => {
|
|
if (deletingAreaId.value) return
|
|
areaPendingDelete.value = null
|
|
}
|
|
|
|
const handleDeleteArea = async () => {
|
|
if (!areaPendingDelete.value) return
|
|
|
|
const area = areaPendingDelete.value
|
|
|
|
deletingAreaId.value = area.id
|
|
actionError.value = ''
|
|
|
|
try {
|
|
const res = await deleteArea(area.id)
|
|
if (res.ok) {
|
|
if (editingAreaId.value === area.id) {
|
|
closeCreateModal()
|
|
}
|
|
await refresh()
|
|
areaPendingDelete.value = null
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Failed to delete area', err)
|
|
actionError.value = err?.data?.message || 'Errore durante eliminazione area.'
|
|
} finally {
|
|
deletingAreaId.value = null
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="container my-5">
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h1 class="h3 font-weight-bold mb-0">Gestione Aree</h1>
|
|
<div>
|
|
<NuxtLink to="/dashboard" class="btn btn-outline-secondary btn-sm me-2">Torna al pannello admin</NuxtLink>
|
|
<button class="btn btn-primary btn-sm" @click="openCreateModal">
|
|
<BIIcon name="it-plus" size="xs" class="me-1" />
|
|
Nuova Area
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="pending" class="text-center py-5">
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|
</div>
|
|
|
|
<div v-else-if="areas.length === 0" class="alert alert-info">
|
|
Nessuna area configurata.
|
|
</div>
|
|
|
|
<template v-else>
|
|
<!-- Main view map showing all areas -->
|
|
<div class="card mb-4 shadow-sm border-0">
|
|
<div id="mainMap" class="rounded" style="height: 400px; width: 100%; z-index: 1;"></div>
|
|
</div>
|
|
|
|
<div v-if="actionError" class="alert alert-danger">
|
|
{{ actionError }}
|
|
</div>
|
|
|
|
<div v-else class="table-responsive">
|
|
<table class="table table-hover align-middle bg-white shadow-sm rounded">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Nome</th>
|
|
<th>Descrizione</th>
|
|
<th>GeoJSON</th>
|
|
<th class="text-end">Azioni</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="area in areas" :key="area.id">
|
|
<td>{{ area.id }}</td>
|
|
<td><strong>{{ area.name }}</strong></td>
|
|
<td>{{ area.description || '-' }}</td>
|
|
<td>
|
|
<span v-if="area.boundary_geojson" class="badge border border-primary text-primary">Configurato</span>
|
|
<span v-else class="badge border border-danger text-danger">Mancante</span>
|
|
</td>
|
|
<td class="text-end">
|
|
<button class="btn btn-sm btn-outline-primary me-2" @click="openEditModal(area)">
|
|
Modifica
|
|
</button>
|
|
<button
|
|
class="btn btn-sm btn-outline-danger ms-2"
|
|
:disabled="deletingAreaId === area.id"
|
|
@click="askDeleteArea(area)"
|
|
>
|
|
<span v-if="deletingAreaId === area.id" class="spinner-border spinner-border-sm me-1"></span>
|
|
Elimina
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Modale Creazione/Modifica -->
|
|
<div v-if="isCreating" class="modal-backdrop fade show d-block" style="background: rgba(0,0,0,0.5); z-index: 1040;"></div>
|
|
<div v-if="isCreating" class="modal fade show d-block" tabindex="-1" style="z-index: 1050;">
|
|
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">{{ modalTitle }}</h5>
|
|
<button type="button" class="btn-close" @click="closeCreateModal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form @submit.prevent="handleCreate">
|
|
<div class="row">
|
|
<div class="col-md-5">
|
|
<div class="mb-3">
|
|
<label class="form-label">Nome Area</label>
|
|
<input type="text" class="form-control" v-model="formData.name" required placeholder="Es. Centro Storico" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Descrizione (opzionale)</label>
|
|
<textarea class="form-control" v-model="formData.description" rows="3"></textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">GeoJSON Generato</label>
|
|
<textarea
|
|
class="form-control font-monospace text-sm"
|
|
rows="6"
|
|
v-model="formData.geojson"
|
|
required
|
|
readonly
|
|
placeholder='{ "type": "Polygon", "coordinates": [...] }'
|
|
></textarea>
|
|
<div v-if="jsonError" class="form-text text-danger mt-1">{{ jsonError }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-7">
|
|
<div class="mb-2 d-flex justify-content-between align-items-center">
|
|
<label class="form-label mb-0">Disegna Poligono sulla Mappa</label>
|
|
<button type="button" class="btn btn-outline-danger btn-sm" @click="clearMap">
|
|
Pulisci Mappa
|
|
</button>
|
|
</div>
|
|
<div class="alert alert-light border small mb-2 p-2">
|
|
Clicca sulla mappa per aggiungere vertici al poligono. Sono necessari almeno 3 punti. Sono ammessi solo punti nel Comune di Bari.
|
|
</div>
|
|
<div id="areaMap" class="rounded border" style="height: 400px; width: 100%; z-index: 1;"></div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer d-flex justify-content-end gap-2">
|
|
<button type="button" class="btn btn-outline-secondary" @click="closeCreateModal">Annulla</button>
|
|
<button type="button" class="btn btn-primary" :disabled="!formData.name || !formData.geojson" @click="handleCreate">
|
|
{{ submitLabel }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmModal
|
|
:open="areaPendingDelete !== null"
|
|
title="Elimina area"
|
|
:message="areaDeleteMessage"
|
|
confirm-label="Elimina area"
|
|
confirm-class="btn-danger"
|
|
:pending="deletingAreaId !== null"
|
|
@cancel="closeDeleteAreaModal"
|
|
@confirm="handleDeleteArea"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
#areaMap, #mainMap {
|
|
background: #e9ecef;
|
|
}
|
|
|
|
.modal-dialog {
|
|
margin-block: 0!important;
|
|
}
|
|
</style> |